aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/mapgen
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-06 23:52:03 +0200
committergodosa <godosa@godosa.eu>2026-10-06 23:52:03 +0200
commit346b1c5195bffc71ceaa9262453e3c189656400b (patch)
tree01ac0d31e2724cd6abcc689a5a228e2cbea2f6cf /mapgen
downloadworldgen-346b1c5195bffc71ceaa9262453e3c189656400b.tar.gz
worldgen-346b1c5195bffc71ceaa9262453e3c189656400b.zip
worldgen: initial public history
Diffstat (limited to 'mapgen')
-rw-r--r--mapgen/__init__.py7
-rw-r--r--mapgen/check.py272
-rw-r--r--mapgen/climate.py249
-rw-r--r--mapgen/config.py269
-rw-r--r--mapgen/crust.py97
-rw-r--r--mapgen/elevation.py197
-rw-r--r--mapgen/environment.py158
-rw-r--r--mapgen/eras.py219
-rw-r--r--mapgen/erosion.py54
-rw-r--r--mapgen/events.py87
-rw-r--r--mapgen/fields.py36
-rw-r--r--mapgen/geo.py200
-rw-r--r--mapgen/graph.py477
-rw-r--r--mapgen/grid.py121
-rw-r--r--mapgen/hydrology.py231
-rw-r--r--mapgen/ice.py37
-rw-r--r--mapgen/minerals.py172
-rw-r--r--mapgen/newworld.py117
-rw-r--r--mapgen/noise.py111
-rw-r--r--mapgen/ocean.py166
-rw-r--r--mapgen/pipeline.py180
-rw-r--r--mapgen/plateaus.py179
-rw-r--r--mapgen/plates.py86
-rw-r--r--mapgen/projections.py150
-rw-r--r--mapgen/render.py526
-rw-r--r--mapgen/seabed.py95
-rw-r--r--mapgen/sketch.py121
-rw-r--r--mapgen/sphere.py68
-rw-r--r--mapgen/testdata/world.toml31
-rw-r--r--mapgen/testing.py59
-rw-r--r--mapgen/viewer_export.py39
-rw-r--r--mapgen/zones.py38
32 files changed, 4849 insertions, 0 deletions
diff --git a/mapgen/__init__.py b/mapgen/__init__.py
new file mode 100644
index 0000000..63fd969
--- /dev/null
+++ b/mapgen/__init__.py
@@ -0,0 +1,7 @@
+"""World generator."""
+import os
+
+# OpenBLAS threads spin ~0.1 s after each call before sleeping; with solves on several threads (and other jobs on the
+# machine) that spinning steals the cores doing the work. A short timeout changes nothing in how work is split, so
+# results stay the same bits (the thread *count* would not: see pipeline/graph notes). Only before numpy loads.
+os.environ.setdefault("OPENBLAS_THREAD_TIMEOUT", "4")
diff --git a/mapgen/check.py b/mapgen/check.py
new file mode 100644
index 0000000..f3f4639
--- /dev/null
+++ b/mapgen/check.py
@@ -0,0 +1,272 @@
+"""`mapgen.py check`: physical sanity checks (fail) + environment summary (info)."""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import numpy as np
+
+from . import plateaus as PL, zones as ZN
+from .climate import DEFAULTS as CLIMATE_DEFAULTS
+from .config import params
+from .environment import GROUND_NAMES, LANDFORM_NAMES, REGIONS
+from .fields import gravity_mod
+from .graph import gradient
+from .ice import ICE_NAMES
+from .sphere import gc_dist_km, latlon_to_xyz
+
+
+def check_land_fraction(z, area, target, tol=0.02):
+ f = area[z > 0].sum() / area.sum()
+ return None if abs(f - target) <= tol else f"land fraction {f:.3f} not within {tol} of target {target}"
+
+
+def check_hypsometry(z, area):
+ edges = np.arange(-11000, 12500, 500)
+ h, _ = np.histogram(z, bins=edges, weights=area)
+ c = (edges[:-1] + edges[1:]) / 2
+ ocean = (c >= -7000) & (c <= -1000)
+ landb = (c >= -500) & (c <= 3000)
+ io, il = np.flatnonzero(ocean)[np.argmax(h[ocean])], np.flatnonzero(landb)[np.argmax(h[landb])]
+ valley = h[io:il + 1].min()
+ if h[io] > 1.5 * valley and h[il] > 1.5 * valley:
+ return None
+ return "hypsometry not bimodal (expected separate ocean-floor and continent peaks)"
+
+
+def check_max_elevation(z, gmod, cap=12000.0):
+ lim = cap * np.clip(1.0 / gmod, 1.0, 3.0)
+ bad = z > lim + 1.0
+ return None if not bad.any() else f"{int(bad.sum())} cells above {cap:.0f} m (outside low-g zones)"
+
+
+def check_drainage(recv, z, endorheic, terminal_ok=None):
+ roots = recv == np.arange(len(recv))
+ bad = roots & (z > 0) & ~endorheic
+ if bad.any():
+ return f"{int(bad.sum())} land cells are sinks outside endorheic basins"
+ if terminal_ok is not None:
+ dry = roots & endorheic & ~terminal_ok
+ if dry.any():
+ return f"{int(dry.sum())} endorheic terminals are neither lake nor salt flat"
+ return None
+
+
+def check_no_uphill(zf, recv, z, endorheic, lake=None):
+ ar = np.arange(len(recv))
+ sel = (z > 0) & ~endorheic & (recv != ar)
+ if lake is not None:
+ sel &= ~lake # flat lake surfaces: routing direction is a convention
+ bad = sel & (zf[recv] >= zf)
+ return None if not bad.any() else f"{int(bad.sum())} cells route uphill on the filled surface"
+
+
+def _band_means(g, f, edges):
+ a = np.abs(g.lat)
+ return [np.sum(f[(a >= lo) & (a < hi)] * g.area_km2[(a >= lo) & (a < hi)]) /
+ max(g.area_km2[(a >= lo) & (a < hi)].sum(), 1e-9) for lo, hi in zip(edges, edges[1:])]
+
+
+def check_temperature(g, t_mean):
+ m = _band_means(g, t_mean, list(range(0, 91, 10)))
+ ok = all(b <= a + 0.5 for a, b in zip(m, m[1:])) and m[0] > m[-1] + 10
+ return None if ok else f"zonal-mean temperature does not fall poleward: {np.round(m, 1).tolist()}"
+
+
+def check_rain_bands(g, p_ann, hadley_edge):
+ """Dry belt ≈ Hadley edge −2..+8°, storm track ≈ edge +12..+25° (scaled from Earth's 30° edge)."""
+ h = hadley_edge
+ eq = _band_means(g, p_ann, [0, 10])[0]
+ sub = _band_means(g, p_ann, [h - 2, h + 8])[0]
+ mid = _band_means(g, p_ann, [h + 12, h + 25])[0]
+ if eq > sub and mid > sub:
+ return None
+ return (f"no subtropical dry belt: P(0-10)={eq:.0f}, P({h - 2:.0f}-{h + 8:.0f})={sub:.0f}, "
+ f"P({h + 12:.0f}-{h + 25:.0f})={mid:.0f} mm/yr")
+
+
+def check_rain_shadow(g, data):
+ z = np.maximum(np.asarray(data["elevation_eroded_m"], dtype=np.float64), 0.0) / 1000.0
+ grad = gradient(g, z)
+ land = z > 0
+ ww, lw = [], []
+ for s in ("jun", "dec"):
+ up = np.sum(np.asarray(data[f"wind_{s}"], dtype=np.float64) * grad, axis=1)
+ p = np.asarray(data[f"P_{s}"])
+ ww.append(p[land & (up > 0.02)])
+ lw.append(p[land & (up < -0.02)])
+ w, l = np.concatenate(ww), np.concatenate(lw)
+ if len(w) == 0 or len(l) == 0:
+ return None
+ return None if w.mean() > l.mean() else f"windward slopes ({w.mean():.0f}) not wetter than lee ({l.mean():.0f})"
+
+
+def _shares(values, area, names, mask):
+ tot = area[mask].sum()
+ out = []
+ for i, nm in enumerate(names):
+ s = area[mask & (values == i)].sum() / max(tot, 1e-9)
+ if s >= 0.005:
+ out.append(f"{nm} {100 * s:.1f}%")
+ return ", ".join(out)
+
+
+
+def check_plateaus(g, data, plateaus, seed) -> list:
+ """Spec §8: every plateau's top within top_m (± relief), hidden ones ≤ −500 m, island ones with land."""
+ if not plateaus:
+ return []
+ z = np.asarray(data["elevation_eroded_m"], dtype=np.float64)
+ ocean, ids = np.asarray(data["ocean"]), np.asarray(data["plateau_id"])
+ out = []
+ for k, p in enumerate(plateaus):
+ idx = np.flatnonzero(ids == k)
+ if len(idx) == 0:
+ out.append(f"plateau {p['name']}: no cells at this resolution")
+ continue
+ _, b = PL.semi_axes(p)
+ core = idx[(1.0 - PL.rho(g.xyz[idx], p, seed, g.radius_km)) * b > PL.MARGIN_KM]
+ lo, hi = p["top_m"]
+ if len(core):
+ med = float(np.median(-z[core]))
+ if not lo - PL.RELIEF_M <= med <= hi + PL.RELIEF_M:
+ out.append(f"plateau {p['name']}: median top depth {med:.0f} m outside {lo:.0f}–{hi:.0f} m")
+ if p.get("islands", False):
+ isl = [g.cell_index(c["lat"], c["lon"]) for c in PL.features(p, seed, g.radius_km)["cones"] if c["island"]]
+ if ocean[idx].all() and all(ocean[i] for i in isl):
+ out.append(f"plateau {p['name']}: no island breaks the surface")
+ elif z[idx].max() > -500.0:
+ out.append(f"plateau {p['name']}: a hidden plateau reaches {z[idx].max():.0f} m (limit −500 m)")
+ return out
+
+
+def check_zones(g, data, max_o2_points=0.23, max_g=0.014, per_km=30.0) -> list:
+ """Spec §5, the gradual rule: steepest change per day's walk of the O₂ fraction (points) and of gravity."""
+ out = []
+ for key, scale, lim, unit in (("o2_fraction", 100.0, max_o2_points, "O₂ points"), ("gravity_g", 1.0, max_g, "g")):
+ if key not in data:
+ continue
+ f = np.asarray(data[key], dtype=np.float64) * scale
+ steep = float(np.max(np.abs(f[g.dst] - f[g.src]) / g.edge_km)) * per_km
+ if steep > lim + 1e-9:
+ out.append(f"zones: {key} changes {steep:.3f} {unit} per {per_km:.0f} km (limit {lim})")
+ return out
+
+
+def edited_areas(xyz, tect, radius_km, plateau_km=300.0, patch_km=1500.0):
+ """Cells the revision changes by design: plateaus + 300 km, land patches + 1,500 km, low-gravity zones
+ (their relief)."""
+ xyz = np.asarray(xyz, dtype=np.float64)
+ near = lambda c, r: gc_dist_km(xyz, latlon_to_xyz(*c), radius_km) < r
+ m = np.zeros(len(xyz), bool)
+ for p in tect.get("plateau", []):
+ m |= near(p["center"], PL.semi_axes(p)[0] * (1 + PL.EDGE_WARP) / (1 - PL.EDGE_WARP) + plateau_km)
+ for p in tect.get("land_patch", []):
+ m |= near(p["center"], p["radius_km"] * 1.3 + patch_km)
+ for z in tect.get("zone", []):
+ if z["field"] == "gravity":
+ m |= near(z["center"], z["radius_km"] / (1 - ZN.WARP))
+ return m
+
+
+O2_FIELDS = ("m_o2_zones", "o2_fraction", "po2_bar") # what the base's O₂ zones change (no relief)
+
+
+def o2_areas(xyz, tect, radius_km):
+ """Cells the base's O₂ zones reach: compare skips only the O₂ fields there."""
+ xyz = np.asarray(xyz, dtype=np.float64)
+ m = np.zeros(len(xyz), bool)
+ for z in tect.get("zone", []):
+ if z["field"] == "o2":
+ m |= gc_dist_km(xyz, latlon_to_xyz(*z["center"]), radius_km) < z["radius_km"] / (1 - ZN.WARP)
+ return m
+
+
+def compare(old: dict, new: dict, exclude, extra=None) -> list:
+ """Per field, over the cells outside `exclude` (and outside extra[field] for that field): share changed, largest
+ change, 99th percentile."""
+ keep0 = ~np.asarray(exclude, bool)
+ lines = [f"cells compared: {int(keep0.sum())} of {len(keep0)} (outside the edited areas)"]
+ for k in sorted(set(old) & set(new)):
+ a, b = np.asarray(old[k]), np.asarray(new[k])
+ if a.shape != b.shape or a.shape[:1] != keep0.shape or a.dtype.kind not in "biuf" or b.dtype.kind not in "biuf":
+ continue
+ keep = keep0 & ~np.asarray(extra[k], bool) if extra and k in extra else keep0
+ a, b = a.astype(np.float64)[keep], b.astype(np.float64)[keep]
+ with np.errstate(invalid="ignore"):
+ diff = np.where((a == b) | (np.isnan(a) & np.isnan(b)), 0.0, np.abs(b - a))
+ if diff.ndim > 1:
+ diff = diff.reshape(len(diff), -1).max(axis=1)
+ if not len(diff):
+ lines.append(f"{k}: no cells to compare")
+ continue
+ lines.append(f"{k}: {np.mean(diff > 0):.1%} changed, max {diff.max():.4g}, p99 {np.percentile(diff, 99):.4g}")
+ return lines
+
+
+def compare_report(root: Path, res: int, old_path: Path) -> int:
+ from . import config as C
+ _, tect = C.load(root)
+ out = root / "out" / f"r{res}"
+ with np.load(old_path) as z:
+ old = {k: z[k] for k in z.files}
+ with np.load(out / "cells.npz") as z:
+ new = {k: z[k] for k in z.files}
+ for name, arrays in ((old_path, old), (out / "cells.npz", new)):
+ if "g_ids" not in arrays:
+ print(f"error: {name} has no g_ids: not the cells.npz of a world build")
+ return 2
+ if not np.array_equal(old["g_ids"], new["g_ids"]):
+ print("error: the two worlds have different cells (another resolution?)")
+ return 2
+ radius = float(json.loads((out / "cells_meta.json").read_text())["radius_km"])
+ o2 = o2_areas(new["g_xyz"], tect, radius)
+ for line in compare(old, new, edited_areas(new["g_xyz"], tect, radius), {k: o2 for k in O2_FIELDS}):
+ print(line)
+ return 0
+
+def info_lines(ctx):
+ g, d = ctx.grid, ctx.data
+ z = np.asarray(d["elevation_eroded_m"])
+ land = ~np.asarray(d["ocean"]) if "ocean" in d else z > 0
+ a = g.area_km2
+ sk = np.asarray(d["sk_land"]) > 0.5
+ iou = (land & sk).sum() / max((land | sk).sum(), 1)
+ sites = PL.site_report(g, d, ctx.tect["plateau"]) if ctx.tect.get("plateau") and "plateau_id" in d else []
+ return [f"cells {g.n} (H3 res {ctx.res}); land {100 * a[land].sum() / a.sum():.1f}% "
+ f"({a[land].sum() / 1e6:.0f}M km², Earth: 29%, 149M km²)",
+ f"sketch land overlap (IoU): {iou:.2f}",
+ "regions: " + _shares(np.asarray(d["hold_region"]), a, REGIONS, land),
+ "landforms: " + _shares(np.asarray(d["landform"]), a, LANDFORM_NAMES, land),
+ "ground: " + _shares(np.asarray(d["ground"]), a, GROUND_NAMES, land),
+ "ice: " + _shares(np.asarray(d["ice"]), a, ICE_NAMES, np.ones(g.n, bool))] + [f"site: {s}" for s in sites]
+
+
+def run_checks(ctx):
+ g, d, cfg = ctx.grid, ctx.data, ctx.cfg
+ z = np.asarray(d["elevation_eroded_m"], dtype=np.float64)
+ if "ocean" in d: # interior lows below sea level are land: sign-correct z for the land/sea checks
+ z = np.where(np.asarray(d["ocean"]), np.minimum(z, -0.001), np.maximum(z, 0.001))
+ endo = np.asarray(d["endorheic"])
+ results = [
+ check_land_fraction(z, g.area_km2, cfg["build"]["land_fraction"]),
+ check_hypsometry(z, g.area_km2),
+ check_max_elevation(z, gravity_mod(cfg, d["m_gravity_zones"])),
+ check_drainage(np.asarray(d["recv"]), z, endo, np.asarray(d["lake"]) | np.asarray(d["salt_flat"])),
+ check_no_uphill(np.asarray(d["z_filled_m"]), np.asarray(d["recv"]), z, endo, np.asarray(d["lake"])),
+ check_temperature(g, np.asarray(d["T_mean"])),
+ check_rain_bands(g, np.asarray(d["P_ann"]), params(cfg, "climate", CLIMATE_DEFAULTS)["hadley_edge_deg"]),
+ check_rain_shadow(g, d),
+ ]
+ results += check_plateaus(g, d, ctx.tect.get("plateau", []), ctx.seed) + check_zones(g, d)
+ return [r for r in results if r], info_lines(ctx)
+
+
+def report(ctx) -> int:
+ fails, infos = run_checks(ctx)
+ for line in infos:
+ print("info:", line)
+ for f in fails:
+ print("FAIL:", f)
+ print("check:", "OK" if not fails else f"{len(fails)} failure(s)")
+ return 1 if fails else 0
diff --git a/mapgen/climate.py b/mapgen/climate.py
new file mode 100644
index 0000000..491675c
--- /dev/null
+++ b/mapgen/climate.py
@@ -0,0 +1,249 @@
+"""Stage `climate`: seasonal insolation → temperature, 3-cell winds + monsoons, moisture transport → rain."""
+from __future__ import annotations
+
+import numpy as np
+from scipy import sparse
+
+from . import ocean as OC
+from .config import params
+from .graph import bicgstab_jacobi, distance_to, gradient, nearest_source, pmap, smooth_km
+from .grid import rowdot_at
+from .sphere import east_north, latlon_to_xyz, rotate_about, tangent_dir
+
+S0 = 1361.0
+SEA_FREEZE_C = -1.8 # the exported SST never goes below sea water's freezing point (ice-covered sea)
+SEASONS = ("jun", "dec", "eq")
+DEFAULTS = {
+ "t_a": -55.2, "t_b": 0.3206, "t_c": -2.974e-4, "land_seasonal": 0.45, "land_summer": 0.9, "continentality_summer_max": 1.5, "ocean_seasonal": 0.15,
+ "continentality_km": 1500.0, "continentality_max": 1.8, "lapse_c_per_km": 6.5,
+ "current_c": 4.0, "current_reach_km": 600.0, "current_leak_km": 150.0, "heat_transport_km": 300.0,
+ "hadley_edge_deg": 20.0, "ferrel_edge_deg": 55.0, "itcz_shift_deg": 8.0,
+ "trade_u": -6.0, "trade_v": 2.0, "westerly_u": 8.0, "westerly_v": 1.0, "polar_u": -4.0, "polar_v": 1.0,
+ "monsoon_k": 1.0, "monsoon_length_km": 1500.0, "monsoon_speed_scale": 6000.0,
+ "coriolis_min_deg": 20.0, "coriolis_span_deg": 50.0,
+ "base_rate": 0.25, "conv_rate": 2.0, "front_rate": 0.8, "front_lat_deg": 40.0, "front_width_deg": 10.0, "itcz_width_deg": 8.0, "oro_rate": 20.0, "subsidence": 0.2,
+ "min_rate": 0.05, "cc_per_c": 0.07, "recycle": 0.83, "eddy_k_m2s": 2.2e6,
+ "eddy_wind_ms": 8.0,
+ "global_mean_mm": 1000.0,
+ "lock": False, "lock_at": [0.0, 0.0], "lock_day_c": 120.0, "lock_night_c": -200.0, "lock_wind_ms": 10.0,
+ "lock_melt_c": 0.0, "lock_melt_width_c": 25.0,
+}
+
+
+def t_of_q(q, P):
+ """Radiative-equilibrium-like surface temperature (°C) from insolation (W/m²); quadratic fit to Earth-like
+ zonal means for a 20° tilt: equator 27, 45° ≈ 15, 60° ≈ 3, pole ≈ −22 (concave: damps polar-day summers)."""
+ return P["t_a"] + P["t_b"] * q + P["t_c"] * q * q
+
+
+def declinations(tilt):
+ return {"jun": tilt, "dec": -tilt, "eq": 0.0}
+
+
+def insolation(lat, decl):
+ """Daily-mean top-of-atmosphere insolation (W/m²)."""
+ phi = np.radians(np.clip(lat, -89.9999, 89.9999))
+ d = np.radians(decl)
+ h0 = np.arccos(np.clip(-np.tan(phi) * np.tan(d), -1.0, 1.0))
+ return S0 / np.pi * (h0 * np.sin(phi) * np.sin(d) + np.cos(phi) * np.cos(d) * np.sin(h0))
+
+
+def zonal_mean(g, f, bin_deg=2.0):
+ b = np.floor((g.lat + 90.0) / bin_deg).astype(np.int64)
+ s = np.bincount(b, weights=f * g.area_km2)
+ w = np.bincount(b, weights=g.area_km2)
+ return (s / np.maximum(w, 1e-12))[b]
+
+
+def current_anomaly(g, land, P):
+ """Subtropical gyres: cold water off west coasts, warm off east coasts; leaks onto coastal land."""
+ if not land.any():
+ return np.zeros(g.n)
+ d, src = nearest_source(g, np.flatnonzero(land))
+ e, _ = east_north(g.xyz)
+ east_comp = np.sum(tangent_dir(g.xyz, g.xyz[np.maximum(src, 0)]) * e, axis=1)
+ band = np.sin(np.radians((np.clip(np.abs(g.lat), 10.0, 50.0) - 10.0) * 4.5))
+ a = -P["current_c"] * np.sign(east_comp) * band * np.exp(-d / P["current_reach_km"])
+ a[land] = 0.0
+ return smooth_km(g, a, P["current_leak_km"])
+
+
+def temperatures(g, z, land, P, tilt, cur=None):
+ decl = declinations(tilt)
+ Q = {s: insolation(g.lat, d) for s, d in decl.items()}
+ q_ann = (Q["jun"] + Q["dec"] + 2 * Q["eq"]) / 4
+ t_ann = t_of_q(q_ann, P)
+ dist_ocean = distance_to(g, ~land) if (~land).any() else np.full(g.n, 1e4)
+ cont = np.clip(1.0 + dist_ocean / P["continentality_km"], 1.0, P["continentality_max"])
+ cur = current_anomaly(g, land, P) if cur is None else cur
+ lapse = -P["lapse_c_per_km"] * np.maximum(z, 0.0) / 1000.0
+ def season(s):
+ raw = t_of_q(Q[s], P)
+ land_resp = np.where(raw > t_ann, P["land_summer"] * np.minimum(cont, P["continentality_summer_max"]),
+ P["land_seasonal"] * cont) # land heats faster in summer (dry, low heat capacity)
+ resp = np.where(land, land_resp, P["ocean_seasonal"])
+ return smooth_km(g, t_ann + resp * (raw - t_ann) + cur, P["heat_transport_km"]) + lapse
+ return dict(zip(SEASONS, pmap(season, SEASONS))), dist_ocean
+
+
+def biotemperature(tmean, trange, n=12):
+ ph = np.linspace(0.0, 2 * np.pi, n, endpoint=False)
+ t = np.asarray(tmean)[:, None] + (np.asarray(trange)[:, None] / 2) * np.sin(ph)[None, :]
+ return np.clip(t, 0.0, 30.0).mean(axis=1)
+
+
+def band_winds(g, itcz_lat, P):
+ """3-cell surface winds (m/s) relative to the thermal equator."""
+ phi = g.lat - itcz_lat
+ a = np.abs(phi)
+ sgn = np.where(phi >= 0, 1.0, -1.0)
+ s1 = 0.5 * (1 + np.tanh((a - P["hadley_edge_deg"]) / 3.0))
+ s2 = 0.5 * (1 + np.tanh((a - P["ferrel_edge_deg"]) / 4.0))
+ u = (1 - s1) * P["trade_u"] + (s1 - s2) * P["westerly_u"] + s2 * P["polar_u"]
+ v = sgn * (-(1 - s1) * P["trade_v"] + (s1 - s2) * P["westerly_v"] - s2 * P["polar_v"])
+ e, n = east_north(g.xyz)
+ return u[:, None] * e + v[:, None] * n
+
+
+def monsoon_winds(g, T_s, land, P):
+ """Thermal lows over hot land / highs over cold land, flow deflected by Coriolis."""
+ anom = np.where(land, T_s - zonal_mean(g, T_s), 0.0)
+ press = -P["monsoon_k"] * smooth_km(g, anom, P["monsoon_length_km"])
+ flow = -gradient(g, press)
+ theta = np.radians(P["coriolis_min_deg"] + P["coriolis_span_deg"] * np.abs(np.sin(np.radians(g.lat))))
+ return rotate_about(g.xyz, flow, -np.sign(g.lat) * theta) * P["monsoon_speed_scale"]
+
+
+def _rate(per_1000km, spacing_km):
+ return 1.0 - np.exp(-np.maximum(per_1000km, 0.0) * spacing_km / 1000.0)
+
+
+def eddy_mixing(g, P):
+ """kappa · (nbr − I): the per-step eddy mixing with the neighbours — the same for every season (build once)."""
+ kappa = P["eddy_k_m2s"] / (P["eddy_wind_ms"] * g.spacing_km * 1000.0)
+ nbr = sparse.csr_matrix((1.0 / g.counts[g.src], (g.src, g.dst)), shape=(g.n, g.n))
+ return kappa * (nbr - sparse.identity(g.n, format="csr"))
+
+
+def precipitation(g, wind, T, z, land, itcz_lat, P, mixing=None):
+ """Steady-state moisture transport along the wind on the cell graph; returns rain (relative units).
+ mixing: eddy_mixing(g, P), when several seasons share it."""
+ t = g.edge_tangents
+ out = np.maximum(rowdot_at(wind, g.src, t), 0.0)
+ tot = np.bincount(g.src, weights=out, minlength=g.n)
+ frac = np.where(tot[g.src] > 0, out / np.maximum(tot[g.src], 1e-12), 0.0)
+ Tm = sparse.csr_matrix((frac, (g.dst, g.src)), shape=(g.n, g.n))
+ stay = (tot <= 0).astype(np.float64)
+ upslope = np.maximum(np.sum(wind * gradient(g, np.maximum(z, 0.0) / 1000.0), axis=1), 0.0)
+ conv = P["conv_rate"] * np.exp(-((g.lat - itcz_lat) / P["itcz_width_deg"]) ** 2) * np.clip((T - 10.0) / 20.0, 0, 1)
+ subs = P["subsidence"] * np.exp(-((np.abs(g.lat - itcz_lat) - P["hadley_edge_deg"]) / 6.0) ** 2)
+ front = P["front_rate"] * np.exp(-((np.abs(g.lat - itcz_lat) - P["front_lat_deg"]) / P["front_width_deg"]) ** 2)
+ per = np.maximum(P["base_rate"] + conv + front + P["oro_rate"] * upslope - subs, P["min_rate"])
+ r = _rate(per, g.spacing_km)
+ evap = np.where(land, 0.0, np.exp(P["cc_per_c"] * (np.clip(T, -2.0, 35.0) - 25.0)))
+ # per advection step (one cell, time h/U): rain out, move downwind, eddy-mix with neighbours
+ mix = eddy_mixing(g, P) if mixing is None else mixing
+ eye = sparse.identity(g.n, format="csr")
+ keep = sparse.diags(1.0 - r)
+ recyc = sparse.diags(np.where(land, P["recycle"] * r, 0.0)) # land evapotranspiration returns rain
+ system = (eye - (Tm @ keep + sparse.diags(stay) @ keep) - mix - recyc).tocsr()
+ W, info = bicgstab_jacobi(system, evap, evap / np.maximum(r, 1e-6), 1.0 / system.diagonal(), 1e-7, 5000)
+ if info != 0:
+ raise ValueError(f"precipitation: moisture solve did not converge (info={info})")
+ return r * np.maximum(W, 0.0)
+
+
+def run_locked(ctx, g, z, land, P) -> dict:
+ """One face always to the sun: temperature by sun angle, no seasons; surface wind from night to the sun point."""
+ sub = latlon_to_xyz(*P["lock_at"])
+ mu = np.maximum(g.xyz @ sub, 0.0)
+ t = P["lock_night_c"] + (P["lock_day_c"] - P["lock_night_c"]) * mu ** 0.25
+ lapse = -P["lapse_c_per_km"] * np.maximum(z, 0.0) / 1000.0
+ T = smooth_km(g, t, P["heat_transport_km"]) + lapse
+ dist_ocean = distance_to(g, ~land) if (~land).any() else np.full(g.n, 1e4)
+ wind = P["lock_wind_ms"] * tangent_dir(g.xyz, np.broadcast_to(sub, g.xyz.shape))
+ rain = np.exp(-((T - P["lock_melt_c"]) / P["lock_melt_width_c"]) ** 2) # meltwater and frost in the twilight ring
+ k = P["global_mean_mm"] / max(np.sum(rain * g.area_km2) / g.area_km2.sum(), 1e-12)
+ out = {}
+ for s in SEASONS:
+ out[f"wind_{s}"] = wind.astype(np.float32)
+ out[f"P_{s}"] = rain * k
+ out[f"T_{s}"] = T
+ out["P_ann"] = rain * k
+ out["T_mean"] = T
+ out["T_range"] = np.zeros(g.n)
+ out["T_min"] = T
+ out["biotemp"] = biotemperature(T, out["T_range"])
+ out["PET"] = 58.93 * out["biotemp"]
+ out["dist_ocean_km"] = dist_ocean
+ return out
+
+
+def _still_ocean(g, t_mean):
+ """No circulation (ocean disabled, locked world): zero currents/upwelling/productivity, SST = T_mean."""
+ z = np.zeros(g.n, np.float32)
+ return {"current": np.zeros((g.n, 3), np.float32), "current_speed": z, "sst": np.asarray(t_mean, np.float32),
+ "upwelling": z.copy(), "productivity": z.copy()}
+
+
+def run(ctx) -> dict:
+ g = ctx.grid
+ P = params(ctx.cfg, "climate", DEFAULTS)
+ tilt = float(ctx.cfg["planet"]["tilt_deg"])
+ (z,) = ctx.need("elevation_eroded_m")
+ z = z.astype(np.float64)
+ water = ctx.data.get("open_water", ctx.data.get("ocean")) # big inland basins are water to the air
+ land = ~np.asarray(water) if water is not None else z > 0
+ O = params(ctx.cfg, "ocean", OC.DEFAULTS)
+ if P["lock"]:
+ out = run_locked(ctx, g, z, land, P)
+ out.update(_still_ocean(g, out["T_mean"]))
+ return out
+ sea = ~land
+ coupled = bool(O["enabled"]) and bool(sea.any())
+ T, dist_ocean = temperatures(g, z, land, P, tilt, cur=np.zeros(g.n) if coupled else None)
+ decl = declinations(tilt)
+ def season_wind(s):
+ itcz = P["itcz_shift_deg"] * decl[s] / max(tilt, 1e-9)
+ wind = band_winds(g, itcz, P)
+ return wind + monsoon_winds(g, T[s], land, P) if s != "eq" else wind
+ winds = dict(zip(SEASONS, pmap(season_wind, SEASONS)))
+ if coupled: # one pass: winds from current-free temperatures, then currents carry heat
+ day = float(ctx.cfg["planet"]["day_hours"])
+ w_ann = (winds["jun"] + winds["dec"] + 2 * winds["eq"]) / 4
+ u = OC.currents(g, sea, w_ann, O, day)
+ T_eq = (T["jun"] + T["dec"] + 2 * T["eq"]) / 4
+ T_s = OC.sst(g, sea, u, T_eq, O)
+ cur = smooth_km(g, np.where(sea, T_s - T_eq, 0.0), P["current_leak_km"])
+ T, _ = temperatures(g, z, land, P, tilt, cur=cur)
+ out = {}
+ mixing = eddy_mixing(g, P)
+ rain = dict(zip(SEASONS, pmap(lambda s: precipitation(g, winds[s], T[s], z, land,
+ P["itcz_shift_deg"] * decl[s] / max(tilt, 1e-9), P, mixing),
+ SEASONS)))
+ del mixing
+ for s in SEASONS:
+ out[f"wind_{s}"] = winds[s].astype(np.float32)
+ ann = (rain["jun"] + rain["dec"] + 2 * rain["eq"]) / 4
+ k = P["global_mean_mm"] / max(np.sum(ann * g.area_km2) / g.area_km2.sum(), 1e-12)
+ for s in SEASONS:
+ out[f"P_{s}"] = rain[s] * k
+ out[f"T_{s}"] = T[s]
+ out["P_ann"] = ann * k
+ out["T_mean"] = (T["jun"] + T["dec"] + 2 * T["eq"]) / 4
+ out["T_range"] = np.abs(T["jun"] - T["dec"])
+ out["T_min"] = np.minimum(T["jun"], T["dec"])
+ out["biotemp"] = biotemperature(out["T_mean"], out["T_range"])
+ out["PET"] = 58.93 * out["biotemp"]
+ out["dist_ocean_km"] = dist_ocean
+ if coupled:
+ sst_c = np.where(sea, np.maximum(T_s, SEA_FREEZE_C), out["T_mean"])
+ w_up = OC.upwelling(g, sea, w_ann, O, day)
+ out.update({"current": u.astype(np.float32),
+ "current_speed": np.linalg.norm(u, axis=1).astype(np.float32),
+ "sst": sst_c.astype(np.float32),
+ "upwelling": w_up.astype(np.float32),
+ "productivity": OC.productivity(g, sea, w_up, z, out["T_range"], sst_c, O).astype(np.float32)})
+ else:
+ out.update(_still_ocean(g, out["T_mean"]))
+ return out
diff --git a/mapgen/config.py b/mapgen/config.py
new file mode 100644
index 0000000..a52986a
--- /dev/null
+++ b/mapgen/config.py
@@ -0,0 +1,269 @@
+"""Load and validate map/config/*.toml."""
+from __future__ import annotations
+
+import tomllib
+from pathlib import Path
+
+
+class ConfigError(ValueError):
+ pass
+
+
+MASK_DEFAULTS = {"o2_range": 0.5, "gravity_range": 0.7}
+
+REQUIRED = {
+ "planet": {
+ "radius_km": (1000.0, 100000.0),
+ "gravity_g": (0.1, 5.0),
+ "day_hours": (1.0, 10000.0),
+ "year_days": (1.0, 100000.0),
+ "tilt_deg": (0.0, 90.0),
+ "sea_level_pressure_bar": (0.01, 100.0),
+ "scale_height_km": (1.0, 100.0),
+ "o2_fraction": (0.0, 1.0),
+ },
+ "build": {
+ "seed": (0, 2**31 - 1),
+ "res_dev": (0, 8),
+ "res_final": (0, 8),
+ "raster_width": (64, 32768),
+ "preview_width": (64, 8192),
+ "land_fraction": (0.01, 0.99),
+ },
+}
+
+
+def _check_ranges(cfg: dict, schema: dict, src: str) -> None:
+ for sec, keys in schema.items():
+ if sec not in cfg:
+ raise ConfigError(f"{src}: missing section [{sec}]")
+ for k, (lo, hi) in keys.items():
+ if k not in cfg[sec]:
+ raise ConfigError(f"{src}: missing [{sec}].{k}")
+ v = cfg[sec][k]
+ if isinstance(v, bool) or not isinstance(v, (int, float)):
+ raise ConfigError(f"{src}: [{sec}].{k} must be a number, got {v!r}")
+ if not lo <= v <= hi:
+ raise ConfigError(f"{src}: [{sec}].{k}={v} outside [{lo}, {hi}]")
+
+
+def _latlon(v, what: str, src: str = "tectonics.toml") -> None:
+ ok = isinstance(v, list) and len(v) == 2 and all(isinstance(x, (int, float)) for x in v)
+ if not ok or not (-90 <= v[0] <= 90 and -180 <= v[1] <= 360):
+ raise ConfigError(f"{src}: {what} must be [lat, lon], got {v!r}")
+
+
+def validate_tectonics(t: dict) -> None:
+ plates = t.get("plate", [])
+ if len(plates) < 2:
+ raise ConfigError("tectonics.toml: need at least 2 [[plate]] tables")
+ seen = set()
+ for p in plates:
+ pid = p.get("id", "?")
+ for k in ("id", "seed", "kind", "motion"):
+ if k not in p:
+ raise ConfigError(f"tectonics.toml: plate {pid} missing {k}")
+ if pid in seen:
+ raise ConfigError(f"tectonics.toml: duplicate plate id {pid}")
+ seen.add(pid)
+ if p["kind"] not in ("continental", "oceanic"):
+ raise ConfigError(f"tectonics.toml: plate {pid} kind must be continental|oceanic")
+ _latlon(p["seed"], f"plate {pid}.seed")
+ m = p["motion"]
+ if not (isinstance(m, list) and len(m) == 2 and 0 <= m[1] <= 20):
+ raise ConfigError(f"tectonics.toml: plate {pid}.motion must be [azimuth_deg, speed 0..20 cm/yr]")
+ for kind in ("lip", "volcano", "hotspot", "microcontinent"):
+ for x in t.get(kind, []):
+ for k in ("name", "center"):
+ if k not in x:
+ raise ConfigError(f"tectonics.toml: [[{kind}]] missing {k}")
+ _latlon(x["center"], f"{kind} {x['name']}.center")
+ if kind in ("lip", "volcano", "microcontinent") and not x.get("radius_km", 0) > 0:
+ raise ConfigError(f"tectonics.toml: {kind} {x['name']} needs radius_km > 0")
+ if kind == "hotspot" and not x.get("length_km", 0) > 0:
+ raise ConfigError(f"tectonics.toml: hotspot {x['name']} needs length_km > 0")
+
+
+ERAS_FILE = "eras.toml" # events and eras: not part of the base world's inputs key (pipeline.inputs_key)
+ZONE_FIELDS = {"gravity_g": (0.05, 5.0), "o2_fraction": (0.0, 1.0), "pressure_bar": (0.01, 10.0),
+ "fire_reactivity": (0.0, 5.0)} # render.CONTINUOUS holds every value in range
+PLATEAU_SPACING_KM = 2500.0
+RESERVED_ERA_KEYS = {"order", "default"}
+
+
+def _num(v, what: str, lo: float, hi: float, src: str = "tectonics.toml") -> None:
+ if isinstance(v, bool) or not isinstance(v, (int, float)) or not lo <= v <= hi:
+ raise ConfigError(f"{src}: {what} must be a number in [{lo}, {hi}], got {v!r}")
+
+
+def _pair(v, what: str, lo: float, hi: float, src: str = "tectonics.toml") -> None:
+ ok = isinstance(v, list) and len(v) == 2 and all(isinstance(x, (int, float)) and not isinstance(x, bool) for x in v)
+ if not ok or not lo <= v[0] <= v[1] <= hi:
+ raise ConfigError(f"{src}: {what} must be [low, high] within [{lo}, {hi}], got {v!r}")
+
+
+def _named(items: list, kind: str, src: str = "tectonics.toml") -> set:
+ seen = set()
+ for x in items:
+ if not isinstance(x.get("name"), str):
+ raise ConfigError(f"{src}: [[{kind}]] needs a name")
+ if x["name"] in seen:
+ raise ConfigError(f"{src}: duplicate {kind} name {x['name']!r}")
+ seen.add(x["name"])
+ return seen
+
+
+def _event(e: dict) -> None:
+ w, src, kind = f"event {e['name']}", ERAS_FILE, e.get("kind")
+ if kind == "disintegrate":
+ _latlon(e.get("center"), f"{w}.center", src)
+ _num(e.get("radius_km"), f"{w}.radius_km", 1.0, 20000.0, src)
+ _num(e.get("depth_m"), f"{w}.depth_m", 1.0, 20000.0, src)
+ elif kind == "volcano":
+ _latlon(e.get("center"), f"{w}.center", src)
+ _num(e.get("radius_km"), f"{w}.radius_km", 1.0, 5000.0, src)
+ _num(e.get("peak_m"), f"{w}.peak_m", -11000.0, 12000.0, src)
+ if e.get("shape", "cone") not in ("cone", "shield", "caldera"):
+ raise ConfigError(f"{src}: {w}.shape must be cone|shield|caldera, got {e.get('shape')!r}")
+ if e.get("peak_mode", "above") not in ("above", "absolute"):
+ raise ConfigError(f"{src}: {w}.peak_mode must be above|absolute, got {e.get('peak_mode')!r}")
+ elif kind == "zone":
+ f = e.get("fields")
+ if not isinstance(f, dict) or not f:
+ raise ConfigError(f"{src}: {w}.fields must set at least one of {sorted(ZONE_FIELDS)}")
+ for k, v in f.items():
+ if k not in ZONE_FIELDS:
+ raise ConfigError(f"{src}: {w}.fields: unknown field {k!r} (known: {sorted(ZONE_FIELDS)})")
+ _num(v, f"{w}.fields.{k}", *ZONE_FIELDS[k], src)
+ shape = e.get("shape")
+ if shape == "circle":
+ _latlon(e.get("center"), f"{w}.center", src)
+ _num(e.get("radius_km"), f"{w}.radius_km", 1.0, 20000.0, src)
+ elif shape == "landmass":
+ _latlon(e.get("seed"), f"{w}.seed", src)
+ _pair(e.get("reach_km"), f"{w}.reach_km", 0.0, 20000.0, src)
+ else:
+ raise ConfigError(f"{src}: {w}.shape must be circle|landmass, got {shape!r}")
+ if "edge_km" in e:
+ _num(e["edge_km"], f"{w}.edge_km", 0.001, 5000.0, src)
+ elif e.get("profile") != "smooth":
+ raise ConfigError(f'{src}: {w} needs edge_km (a sharp edge) or profile = "smooth"')
+ else:
+ raise ConfigError(f"{src}: {w}.kind must be disintegrate|zone|volcano, got {kind!r}")
+
+
+def validate_revision(t: dict, radius_km: float) -> None:
+ """Plateaus, land patches, zones, events and eras."""
+ from .sphere import gc_dist_km, latlon_to_xyz
+ plateaus = t.get("plateau", [])
+ _named(plateaus, "plateau")
+ for p in plateaus:
+ w = f"plateau {p['name']}"
+ _latlon(p.get("center"), f"{w}.center")
+ _num(p.get("area_km2"), f"{w}.area_km2", 1.0e4, 5.0e7)
+ _num(p.get("elongation", 1.0), f"{w}.elongation", 1.0, 10.0)
+ _num(p.get("azimuth_deg", 0.0), f"{w}.azimuth_deg", -360.0, 360.0)
+ _pair(p.get("top_m"), f"{w}.top_m (metres below sea level, shallowest first)", 1.0, 11000.0)
+ if not isinstance(p.get("islands", False), bool):
+ raise ConfigError(f"tectonics.toml: {w}.islands must be true or false")
+ _num(p.get("vent", 1.0), f"{w}.vent", 0.0, 1.0)
+ for i, a in enumerate(plateaus):
+ for b in plateaus[i + 1:]:
+ d = float(gc_dist_km(latlon_to_xyz(*a["center"]), latlon_to_xyz(*b["center"]), radius_km))
+ if d < PLATEAU_SPACING_KM:
+ raise ConfigError(f"tectonics.toml: plateaus {a['name']} and {b['name']} are {d:.0f} km apart "
+ f"(need ≥ {PLATEAU_SPACING_KM:.0f} km)")
+ _named(t.get("land_patch", []), "land_patch")
+ for p in t.get("land_patch", []):
+ w = f"land_patch {p['name']}"
+ _latlon(p.get("center"), f"{w}.center")
+ _num(p.get("radius_km"), f"{w}.radius_km", 1.0, 20000.0)
+ _num(p.get("strength", 1.0), f"{w}.strength", 0.0, 2.0)
+ _num(p.get("edge_noise", 0.0), f"{w}.edge_noise", 0.0, 1.0)
+ _named(t.get("zone", []), "zone")
+ for z in t.get("zone", []):
+ w = f"zone {z['name']}"
+ if z.get("field") not in ("o2", "gravity"):
+ raise ConfigError(f"tectonics.toml: {w}.field must be o2|gravity, got {z.get('field')!r}")
+ _latlon(z.get("center"), f"{w}.center")
+ _num(z.get("radius_km"), f"{w}.radius_km", 1.0, 20000.0)
+ _num(z.get("v"), f"{w}.v", -1.0, 1.0)
+ if z.get("profile", "smooth") != "smooth":
+ raise ConfigError(f"tectonics.toml: {w}.profile must be smooth")
+ names = _named(t.get("event", []), "event", ERAS_FILE)
+ for e in t.get("event", []):
+ _event(e)
+ eras = t.get("eras")
+ if eras is None:
+ return
+ order = eras.get("order")
+ if not (isinstance(order, list) and order and all(isinstance(n, str) for n in order)
+ and len(set(order)) == len(order)) or RESERVED_ERA_KEYS & set(order):
+ raise ConfigError(f"{ERAS_FILE}: [eras].order must list distinct era names (not order/default), got {order!r}")
+ if eras.get("default") not in order:
+ raise ConfigError(f"{ERAS_FILE}: [eras].default {eras.get('default')!r} is not in order {order}")
+ extra = set(eras) - set(order) - RESERVED_ERA_KEYS
+ if extra:
+ raise ConfigError(f"{ERAS_FILE}: [eras] tables {sorted(extra)} are not in order {order}")
+ for n in order:
+ e = eras.get(n)
+ if not isinstance(e, dict) or not isinstance(e.get("label", n), str):
+ raise ConfigError(f"{ERAS_FILE}: missing [eras.{n}] (label, events)")
+ evs = e.get("events", [])
+ if not isinstance(evs, list) or not all(isinstance(x, str) for x in evs):
+ raise ConfigError(f"{ERAS_FILE}: [eras.{n}].events must be a list of event names")
+ if "years" in e:
+ _num(e["years"], f"[eras.{n}].years (since the era before it)", 0.0, 1.0e9, ERAS_FILE)
+ unknown = [x for x in evs if x not in names]
+ if unknown:
+ raise ConfigError(f"{ERAS_FILE}: era {n} names unknown events {unknown}")
+
+
+def era_events(tect: dict, name: str) -> list:
+ """The events of era `name`: those of every era before it in [eras].order, then its own (cumulative)."""
+ eras = tect.get("eras") or {}
+ order = eras.get("order", [])
+ if name not in order:
+ raise ConfigError(f"{ERAS_FILE}: unknown era {name!r} (eras: {order})")
+ by_name = {e["name"]: e for e in tect.get("event", [])}
+ out = []
+ for n in order[: order.index(name) + 1]:
+ out += [by_name[e] for e in eras.get(n, {}).get("events", [])]
+ return out
+
+
+def load(root: Path) -> tuple[dict, dict]:
+ wpath = root / "config" / "world.toml"
+ tpath = root / "config" / "tectonics.toml"
+ for p in (wpath, tpath):
+ if not p.exists():
+ raise ConfigError(f"missing {p}")
+ with open(wpath, "rb") as f:
+ cfg = tomllib.load(f)
+ with open(tpath, "rb") as f:
+ tect = tomllib.load(f)
+ for k in ("event", "eras"):
+ if k in tect:
+ raise ConfigError(f"tectonics.toml: [{k}] belongs in config/{ERAS_FILE} (editing an era must not rebuild "
+ f"the base world)")
+ epath = root / "config" / ERAS_FILE
+ if epath.exists():
+ with open(epath, "rb") as f:
+ e = tomllib.load(f)
+ unknown = set(e) - {"event", "eras"}
+ if unknown:
+ raise ConfigError(f"{ERAS_FILE}: unknown tables {sorted(unknown)} (only [[event]] and [eras])")
+ tect = {**tect, **e}
+ _check_ranges(cfg, REQUIRED, "world.toml")
+ validate_tectonics(tect)
+ validate_revision(tect, float(cfg["planet"]["radius_km"]))
+ return cfg, tect
+
+
+def params(cfg: dict, section: str, defaults: dict) -> dict:
+ """Module defaults overridden by world.toml [section]; unknown keys are errors (typo guard)."""
+ over = cfg.get(section, {})
+ unknown = set(over) - set(defaults)
+ if unknown:
+ raise ConfigError(f"world.toml [{section}]: unknown keys {sorted(unknown)}")
+ return {**defaults, **over}
diff --git a/mapgen/crust.py b/mapgen/crust.py
new file mode 100644
index 0000000..345a1aa
--- /dev/null
+++ b/mapgen/crust.py
@@ -0,0 +1,97 @@
+"""Stage `crust`: continental vs oceanic crust, age classes, oceanic age."""
+from __future__ import annotations
+
+import numpy as np
+
+from .config import params
+from . import plateaus as PL
+from .graph import distance_to, smooth_km
+from .noise import fbm, name_seed, ridged
+from .plates import CONV, DIV
+from .sphere import azimuth_deg, gc_dist_km, latlon_to_xyz
+
+OCEANIC, CRATON, PRE_OROGEN, POST_OROGEN, RIFT, LIP, SCAR, VOLCANO = range(8)
+AGE_NAMES = ["oceanic", "craton (3g era)", "pre-Lightening orogen", "post-Lightening orogen",
+ "rift", "Lightening basalt province", "collapse scar", "overshoot volcano"]
+DEFAULTS = {"continental_threshold": 0.25, "shelf_km": 450.0, "edge_noise": 0.5, "edge_noise_freq": 4.0, "edge_noise_gain": 0.65, "orogen_width_km": 700.0, "rift_width_km": 200.0,
+ "pre_orogen_threshold": 0.72, "half_spreading_km_myr": 30.0, "max_ocean_age_myr": 200.0,
+ "scar_inner": 0.4, "scar_outer": 1.6, "scar_halfwidth_deg": 25.0}
+
+
+def center_dist(g, center):
+ return gc_dist_km(g.xyz, latlon_to_xyz(*center), g.radius_km)
+
+
+def lip_profile(g, lip, seed):
+ r = lip["radius_km"] * (1.0 + 0.25 * fbm(g.xyz, name_seed(seed, lip["name"]), 3, 6.0))
+ x = center_dist(g, lip["center"]) / r
+ return np.clip((1.0 - x) / 0.25, 0.0, 1.0)
+
+
+def _scar_azimuths(v, seed):
+ if "scar_azimuths" in v:
+ return list(v["scar_azimuths"])
+ rng = np.random.default_rng(name_seed(seed, v["name"]))
+ return list(rng.uniform(0, 360, size=2))
+
+
+def patch_field(g, patch: dict, seed: int):
+ """[[land_patch]]: land hint (strength) over a disc whose radius varies ± 25 % · edge_noise (fractal edge)."""
+ d = center_dist(g, patch["center"])
+ r = float(patch["radius_km"])
+ out = np.zeros(g.n)
+ near = d < 1.3 * r
+ if near.any():
+ w = np.clip(2.0 * fbm(g.xyz[near], name_seed(seed, patch["name"]), 6, 12.0), -1.0, 1.0)
+ out[near] = patch.get("strength", 1.0) * (d[near] < r * (1.0 + 0.25 * patch.get("edge_noise", 0.0) * w))
+ return out
+
+
+def run(ctx) -> dict:
+ g = ctx.grid
+ P = params(ctx.cfg, "crust", DEFAULTS)
+ land, land_hint, btype = ctx.need("sk_land", "m_land_hint", "bnd_type")
+ rough = (fbm(g.xyz, ctx.seed + 23, 7, P["edge_noise_freq"], gain=P["edge_noise_gain"])
+ if P["edge_noise"] > 0 else None)
+
+ def continental_of(raw):
+ field = smooth_km(g, raw, P["shelf_km"])
+ if rough is not None: # fractal margins: bays, peninsulas, offshore continental fragments
+ f = np.clip(field, 0.0, 1.0)
+ near = 4.0 * f * (1.0 - f) # strongest at the margin; none deep inland / far offshore
+ field = field + P["edge_noise"] * near * rough / max(float(rough.std()), 1e-12)
+ else:
+ field = np.maximum(field, (raw > 0.5).astype(float))
+ cont = field > P["continental_threshold"]
+ for m in ctx.tect.get("microcontinent", []):
+ cont |= center_dist(g, m["center"]) < m["radius_km"]
+ return cont
+
+ raw = np.clip(land + 0.5 * land_hint, 0.0, 1.0)
+ continental_base = continental_of(raw) # without land patches: its sea level is the world's
+ patches = sum((patch_field(g, p, ctx.seed) for p in ctx.tect.get("land_patch", [])), np.zeros(g.n))
+ continental = continental_of(np.clip(raw + patches, 0.0, 1.0)) if patches.any() else continental_base.copy()
+ plateau_id = PL.cell_ids(g.xyz, ctx.tect.get("plateau", []), ctx.seed, g.radius_km)
+ continental |= plateau_id >= 0 # sunken plateaus: continental crust that never rose
+ d_conv = distance_to(g, btype == CONV)
+ d_div = distance_to(g, btype == DIV)
+ age = np.full(g.n, CRATON, np.int8)
+ age[continental & (ridged(g.xyz, ctx.seed + 21, 4, 4.0) > P["pre_orogen_threshold"])] = PRE_OROGEN
+ age[continental & (d_div < P["rift_width_km"])] = RIFT
+ age[continental & (d_conv < P["orogen_width_km"])] = POST_OROGEN
+ age[~continental] = OCEANIC
+ for lip in ctx.tect.get("lip", []):
+ age[lip_profile(g, lip, ctx.seed) > 0] = LIP
+ for v in ctx.tect.get("volcano", []):
+ d = center_dist(g, v["center"])
+ r = v["radius_km"]
+ age[d < r] = VOLCANO
+ az = azimuth_deg(latlon_to_xyz(*v["center"]), g.xyz)
+ for a in _scar_azimuths(v, ctx.seed):
+ diff = np.abs((az - a + 180.0) % 360.0 - 180.0)
+ age[(diff < P["scar_halfwidth_deg"]) & (d > P["scar_inner"] * r) & (d < P["scar_outer"] * r)] = SCAR
+ oceanic_age = np.minimum(d_div / P["half_spreading_km_myr"], P["max_ocean_age_myr"])
+ ocean_age = np.where(continental & (plateau_id < 0), 0.0, oceanic_age) # plateaus: the floor around them
+ return {"continental": continental, "age_class": age, "ocean_age_myr": ocean_age.astype(np.float32),
+ "d_conv_km": d_conv, "d_div_km": d_div, "continental_base": continental_base, "plateau_id": plateau_id,
+ "land_patch": patches.astype(np.float32)}
diff --git a/mapgen/elevation.py b/mapgen/elevation.py
new file mode 100644
index 0000000..47f0060
--- /dev/null
+++ b/mapgen/elevation.py
@@ -0,0 +1,197 @@
+"""Stage `elevation`: tectonic relief + Lightening provinces + hotspots + hints; sea level solved."""
+from __future__ import annotations
+
+import numpy as np
+
+from .config import params
+from .crust import PRE_OROGEN, SCAR, center_dist, lip_profile, name_seed
+from .fields import gravity_mod
+from .graph import OCEAN_MIN_KM2, distance_to, nearest_source, ocean_mask, smooth_km
+from .noise import fbm, ridged
+from . import plateaus as PL
+from .pipeline import StageError
+from .plates import edge_convergence
+from .sphere import east_north, gc_dist_km, great_circle_point, latlon_to_xyz
+
+OVER, SUB, COLLISION = 1, 2, 3
+DEFAULTS = {
+ "continental_base_m": 400.0, "continental_noise_m": 350.0,
+ "margin_km": 500.0, "shelf_m": -200.0, "slope_km": 250.0,
+ "coast_noise_m": 900.0, "coast_band_km": 600.0, "coast_noise_freq": 6.0,
+ "ridge_depth_m": 2500.0, "age_depth_coeff": 350.0, "abyss_m": 6500.0,
+ "rate_full_m_yr": 0.05, "min_rate_m_yr": 0.005,
+ "trench_depth_m": 4000.0, "trench_width_km": 70.0,
+ "arc_cont_m": 5500.0, "arc_ocean_m": 3500.0, "arc_offset_km": 200.0, "arc_width_km": 110.0,
+ "collision_peak_m": 9500.0, "collision_width_km": 260.0, "plateau_m": 4500.0, "plateau_km": 800.0,
+ "rift_depth_m": 1200.0, "rift_shoulder_m": 800.0,
+ "pre_orogen_m": 1200.0, "lip_m": 1500.0, "lip_step_m": 300.0,
+ "apron_m": 500.0, "scar_drop_m": 1500.0,
+ "hotspot_m": 5500.0, "hotspot_spacing_km": 150.0, "hotspot_radius_km": 70.0,
+ "hint_m": 2500.0, "hint_km": 80.0, "land_hint_m": 800.0, "spire_m": 2500.0, "detail_m": 250.0,
+ "max_land_m": 12000.0, "min_ocean_m": -11000.0,
+}
+
+
+def solve_sea_level(z, area, land_fraction):
+ order = np.argsort(-z)
+ cum = np.cumsum(area[order]) / area.sum()
+ k = min(int(np.searchsorted(cum, land_fraction)), len(z) - 1)
+ return z - z[order[k]]
+
+
+def solve_sea_level_connected(g, z, land_fraction, min_sea_km2=OCEAN_MIN_KM2, iters=30):
+ """Shift z so land = everything outside the connected ocean covers land_fraction (interior pits stay land)."""
+ area, tot = g.area_km2, g.area_km2.sum()
+ if abs(area[~ocean_mask(g, z, min_sea_km2)].sum() / tot - land_fraction) <= 0.5 * area.min() / tot:
+ return z # already there: a flat sea floor would pull the bisection onto it
+ s0 = float(np.asarray(z)[np.argsort(-z)][min(int(np.searchsorted(np.cumsum(area[np.argsort(-z)]) / tot,
+ land_fraction)), len(z) - 1)])
+ lo, hi = s0 - 3000.0, s0 + 3000.0
+ for _ in range(iters):
+ mid = 0.5 * (lo + hi)
+ if area[~ocean_mask(g, z - mid, min_sea_km2)].sum() / tot > land_fraction:
+ lo = mid
+ else:
+ hi = mid
+ return z - 0.5 * (lo + hi)
+
+
+def _smoothstep(a, b, x):
+ t = np.clip((x - a) / (b - a), 0.0, 1.0)
+ return t * t * (3 - 2 * t)
+
+
+def roles(g, plate, vel, continental, ocean_age, plate_continental, min_rate):
+ conv, _ = edge_convergence(g, vel)
+ s, d = g.src, g.dst
+ e = (plate[s] != plate[d]) & (conv > min_rate)
+ cs, cd = continental[s[e]], continental[d[e]]
+ ks, kd = plate_continental[plate[s[e]]], plate_continental[plate[d[e]]]
+ over_s = np.where(cs & ~cd, True, np.where(~cs & cd, False,
+ np.where(ks & ~kd, True, np.where(~ks & kd, False, ocean_age[s[e]] < ocean_age[d[e]]))))
+ role_e = np.where(cs & cd, COLLISION, np.where(over_s, OVER, SUB)).astype(np.int8)
+ order = np.argsort(conv[e])
+ role = np.zeros(g.n, np.int8)
+ role[s[e][order]] = role_e[order]
+ return role
+
+
+def _on_plate(g, plate, role_mask, other=None):
+ d, src = nearest_source(g, np.flatnonzero(role_mask))
+ ok = src >= 0
+ same = ok & (plate == plate[np.maximum(src, 0)])
+ if other is not None:
+ same |= ok & (plate == other[np.maximum(src, 0)])
+ return np.where(same, d, np.inf), np.maximum(src, 0)
+
+
+def hotspot_track(g, h: dict, vel, spacing_km: float):
+ """(unit vector, km from the active end) along a hotspot chain; the chain follows the plate's motion."""
+ c = latlon_to_xyz(*h["center"])
+ v = vel[g.cell_index(*h["center"])]
+ sp = np.linalg.norm(v)
+ t = v / sp if sp > 0 else east_north(c[None])[0][0]
+ return [(great_circle_point(c, t, k * spacing_km, g.radius_km), k * spacing_km)
+ for k in range(int(h["length_km"] // spacing_km) + 1)]
+
+
+def _relief(ctx, g, P, cont):
+ """Heights before the sea-level solve for the continental mask `cont` → (z, role, d_over, d_sub, d_coll)."""
+ R, seed = g.radius_km, ctx.seed
+ (plate, vel, pk, brate, bother, age, ocean_age, d_div, land_hint, sk_mtn, mtn_hint) = ctx.need(
+ "plate", "vel", "plate_continental", "bnd_rate", "bnd_other", "age_class", "ocean_age_myr", "d_div_km",
+ "m_land_hint", "sk_mountains", "m_mountain_hint")
+ f = lambda r: np.clip(r / P["rate_full_m_yr"], 0.3, 1.0)
+
+ # passive-margin profile: interior plateau → coastal ramp → shelf → slope → abyssal floor
+ d_in = distance_to(g, ~cont) if (~cont).any() else np.full(g.n, np.inf)
+ d_out = distance_to(g, cont) if cont.any() else np.full(g.n, np.inf)
+ ramp = _smoothstep(0.0, P["margin_km"], d_in)
+ z_cont = P["shelf_m"] + (P["continental_base_m"] - P["shelf_m"]) * ramp
+ z_cont = z_cont + P["continental_noise_m"] * fbm(g.xyz, seed + 31, 5, 3.0)
+ z_ocean = -np.minimum(P["ridge_depth_m"] + P["age_depth_coeff"] * np.sqrt(ocean_age), P["abyss_m"])
+ z_ocean = P["shelf_m"] + (z_ocean - P["shelf_m"]) * _smoothstep(0.0, P["slope_km"], d_out)
+ z = np.where(cont, z_cont, z_ocean)
+ # multi-scale coastal noise: sea level cuts it → bays, headlands, drowned valleys, offshore islands
+ d_edge = np.where(cont, d_in, d_out)
+ z += P["coast_noise_m"] * np.exp(-d_edge / P["coast_band_km"]) * fbm(g.xyz, seed + 91, 7, P["coast_noise_freq"])
+
+ role = roles(g, plate, vel, cont, ocean_age, pk, P["min_rate_m_yr"])
+ d_over, s_over = _on_plate(g, plate, role == OVER)
+ d_sub, s_sub = _on_plate(g, plate, role == SUB)
+ d_coll, s_coll = _on_plate(g, plate, role == COLLISION, other=bother)
+
+ z += -P["trench_depth_m"] * f(brate[s_sub]) * np.exp(-(d_sub / P["trench_width_km"]) ** 2)
+ arc_h = np.where(cont, P["arc_cont_m"], P["arc_ocean_m"])
+ z += arc_h * f(brate[s_over]) * np.exp(-((d_over - P["arc_offset_km"]) / P["arc_width_km"]) ** 2)
+ w = P["collision_width_km"]
+ z += P["collision_peak_m"] * f(brate[s_coll]) * np.exp(-(d_coll / w) ** 2)
+ plateau = _smoothstep(0.5 * w, 1.5 * w, d_coll) * (1 - _smoothstep(0.7 * P["plateau_km"], P["plateau_km"], d_coll))
+ z += np.where(cont, P["plateau_m"] * f(brate[s_coll]) * plateau, 0.0)
+ rift = -P["rift_depth_m"] * np.exp(-(d_div / 60.0) ** 2) + P["rift_shoulder_m"] * np.exp(-((d_div - 120.0) / 60.0) ** 2)
+ z += np.where(cont, rift, 0.0)
+
+ z += np.where(age == PRE_OROGEN, P["pre_orogen_m"] * ridged(g.xyz, seed + 21, 4, 4.0), 0.0)
+ for lip in ctx.tect.get("lip", []):
+ prof = lip_profile(g, lip, seed)
+ z += np.floor(P["lip_m"] * prof / P["lip_step_m"]) * P["lip_step_m"]
+ for v in ctx.tect.get("volcano", []):
+ d = center_dist(g, v["center"])
+ r, H = v["radius_km"], v.get("height_m", 7000.0)
+ z += H * np.clip(1 - d / r, 0, 1) ** 1.5 - 0.35 * H * np.exp(-(d / (0.12 * r)) ** 2)
+ z += P["apron_m"] * np.exp(-((d - r) / (0.3 * r)) ** 2) * (0.5 + 0.5 * fbm(g.xyz, name_seed(seed, v["name"]), 3, 20.0))
+ z -= np.where(age == SCAR, P["scar_drop_m"], 0.0)
+
+ for h in ctx.tect.get("hotspot", []):
+ peaks = np.zeros(g.n)
+ for p, s in hotspot_track(g, h, vel, P["hotspot_spacing_km"]):
+ dk = gc_dist_km(g.xyz, p, R)
+ hk = P["hotspot_m"] * (1 - s / h["length_km"])
+ peaks = np.maximum(peaks, hk * np.clip(1 - dk / P["hotspot_radius_km"], 0, 1) ** 1.2)
+ z += peaks
+
+ hint = smooth_km(g, np.clip(sk_mtn + mtn_hint, 0, 1), P["hint_km"])
+ z += np.where(cont, P["hint_m"] * hint, 0.0)
+ z += P["land_hint_m"] * land_hint
+ z += P["detail_m"] * fbm(g.xyz, seed + 41, 6, 12.0)
+ return z, role, d_over, d_sub, d_coll
+
+
+def run(ctx) -> dict:
+ g = ctx.grid
+ P = params(ctx.cfg, "elevation", DEFAULTS)
+ continental, sk_land, land_hint, m_grav, m_lock = ctx.need("continental", "sk_land", "m_land_hint",
+ "m_gravity_zones", "m_lock")
+ plateau_id = np.asarray(ctx.data.get("plateau_id", np.full(g.n, -1)))
+ cont = np.asarray(continental) & (plateau_id < 0) # plateaus: ocean until their surfaces are set (§3)
+ base = np.asarray(ctx.data.get("continental_base", continental)) & (plateau_id < 0)
+ z, role, d_over, d_sub, d_coll = _relief(ctx, g, P, cont)
+
+ target = ctx.cfg["build"]["land_fraction"]
+ cont_area = g.area_km2[base].sum() / g.area_km2.sum()
+ if target > cont_area + 0.005:
+ raise StageError(f"land_fraction {target} exceeds continental crust area {cont_area:.3f}: sea level would "
+ f"lift ocean ridges into land; lower [build] land_fraction or raise [crust] shelf_km")
+ min_sea = ctx.cfg.get("erosion", {}).get("min_sea_km2", OCEAN_MIN_KM2)
+ sea_ref = np.zeros(g.n, bool) # sea in the world without land patches
+ if np.array_equal(base, cont):
+ z = solve_sea_level_connected(g, z, target, min_sea)
+ else: # land patches (the eastern continent made whole) must not move every other coast: the sea level of the
+ z_raw = _relief(ctx, g, P, base)[0] # world without them, applied to the world with them
+ z_ref = solve_sea_level_connected(g, z_raw, target, min_sea)
+ z = z - float(np.mean(z_raw - z_ref))
+ sea_ref = ocean_mask(g, z_ref, min_sea)
+ patch = smooth_km(g, np.asarray(ctx.data.get("land_patch", np.zeros(g.n)), dtype=np.float64), P["hint_km"])
+ z += P["land_hint_m"] * patch # a patch is a land hint in height too (bare crust
+ # sits below the world's sea level: land)
+ mult = np.clip(1.0 / gravity_mod(ctx.cfg, m_grav), 1.0, 3.0)
+ spires = P["spire_m"] * (mult - 1) * ridged(g.xyz, ctx.seed + 71, 4, 40.0)
+ z = np.where(z > 0, z * mult + spires, z)
+ target_land = (sk_land + 0.5 * land_hint) > 0.5
+ lock = m_lock > 0.5
+ z = np.where(lock & target_land, np.maximum(z, 50.0), np.where(lock & ~target_land, np.minimum(z, -50.0), z))
+ z = PL.apply(g, z, ctx.tect.get("plateau", []), plateau_id, ctx.seed) # sunken plateaus (after the solve)
+ z = np.clip(z, P["min_ocean_m"], P["max_land_m"] * mult)
+ added = ~ocean_mask(g, z, min_sea) & (sea_ref | (plateau_id >= 0)) # land the sea-level solve never saw
+ return {"elevation_m": z.astype(np.float32), "role": role, "d_over_km": d_over, "d_sub_km": d_sub,
+ "d_coll_km": d_coll, "land_added": added}
diff --git a/mapgen/environment.py b/mapgen/environment.py
new file mode 100644
index 0000000..fbd3c8e
--- /dev/null
+++ b/mapgen/environment.py
@@ -0,0 +1,158 @@
+"""Stage `environment`: Holdridge life zone + seasonality tag + lithology + landform + ground."""
+from __future__ import annotations
+
+import numpy as np
+
+from .config import params
+from .crust import CRATON, LIP, POST_OROGEN, PRE_OROGEN, RIFT, SCAR, VOLCANO
+from .graph import nbr_max, nbr_min, steepest_receivers
+from . import minerals as MN
+from .noise import fbm
+
+REGIONS = ["polar", "subpolar", "boreal", "cool temperate", "warm temperate", "subtropical", "tropical"]
+ZONES = {
+ "polar": ["desert"],
+ "subpolar": ["dry tundra", "moist tundra", "wet tundra", "rain tundra"],
+ "boreal": ["desert", "dry scrub", "moist forest", "wet forest", "rain forest"],
+ "cool temperate": ["desert", "desert scrub", "steppe", "moist forest", "wet forest", "rain forest"],
+ "warm temperate": ["desert", "desert scrub", "thorn steppe", "dry forest", "moist forest", "wet forest",
+ "rain forest"],
+ "subtropical": ["desert", "desert scrub", "thorn woodland", "dry forest", "moist forest", "wet forest",
+ "rain forest"],
+ "tropical": ["desert", "desert scrub", "thorn woodland", "very dry forest", "dry forest", "moist forest",
+ "wet forest", "rain forest"],
+}
+THRESH = { # annual precipitation (mm) upper bounds between successive zones
+ "polar": [], "subpolar": [125, 250, 500], "boreal": [125, 250, 500, 1000],
+ "cool temperate": [125, 250, 500, 1000, 2000], "warm temperate": [125, 250, 500, 1000, 2000, 4000],
+ "subtropical": [125, 250, 500, 1000, 2000, 4000], "tropical": [125, 250, 500, 1000, 2000, 4000, 8000],
+}
+HOLDRIDGE_NAMES = [f"{r} {z}" for r in REGIONS for z in ZONES[r]]
+
+SEAS_F, SEAS_S, SEAS_W, SEAS_M = 0, 1, 2, 3
+SEASONALITY_NAMES = ["even rainfall", "dry summer", "dry winter / summer monsoon", "tropical monsoon"]
+
+LI_OCEAN, LI_GRANITE, LI_LIMESTONE, LI_BASALT, LI_ANDESITE, LI_SANDSTONE, LI_METAMORPHIC = range(7)
+LITHOLOGY_NAMES = ["oceanic basalt", "granite/gneiss", "limestone", "basalt", "andesite", "sandstone/shale",
+ "metamorphic"]
+
+(LF_OCEAN, LF_PLAIN, LF_HILLS, LF_MOUNTAINS, LF_PLATEAU, LF_RIFT, LF_ESCARPMENT, LF_ARC, LF_MASSIF,
+ LF_BASALT_PLATEAU, LF_DUNES, LF_BADLANDS) = range(12)
+LANDFORM_NAMES = ["ocean", "plain", "hills", "mountains", "plateau", "rift valley", "escarpment",
+ "volcanic arc", "volcanic massif", "basalt plateau", "dunes", "badlands"]
+
+(GR_NONE, GR_WETLAND, GR_BOG, GR_FLOODPLAIN, GR_DELTA, GR_MANGROVE, GR_SALT_FLAT, GR_PERMAFROST,
+ GR_KARST) = range(9)
+GROUND_NAMES = ["—", "wetland", "bog", "floodplain", "delta", "mangrove", "salt flat", "permafrost", "karst"]
+
+LEGENDS = {"deposits": MN.LEGEND, "holdridge": HOLDRIDGE_NAMES, "seasonality": SEASONALITY_NAMES, "landform": LANDFORM_NAMES,
+ "lithology": LITHOLOGY_NAMES, "ground": GROUND_NAMES}
+
+DEFAULTS = {"relief_km": 100.0, "coastal_km": 60.0, "dry_ratio_s": 3.0, "dry_ratio_w": 4.0, "monsoon_min_mm": 1500.0, "flat_m_per_km": 1.0,
+ "wet_min_mm": 700.0, "karst_min_mm": 800.0, "small_river_km3_yr": 5.0, "life": True}
+
+
+def holdridge(bio, p, tmin):
+ region = np.select([bio < 1.5, bio < 3, bio < 6, bio < 12, (bio < 24) & (tmin < 0), bio < 24],
+ [0, 1, 2, 3, 4, 5], default=6).astype(np.int8)
+ zone = np.zeros(len(bio), np.int16)
+ offset = 0
+ for ri, r in enumerate(REGIONS):
+ sel = region == ri
+ zone[sel] = offset + np.searchsorted(THRESH[r], p[sel], side="right")
+ offset += len(ZONES[r])
+ return zone, region
+
+
+def seasonality(p_jun, p_dec, lat, tmin, p_ann, P):
+ summer = np.where(lat >= 0, p_jun, p_dec)
+ winter = np.where(lat >= 0, p_dec, p_jun)
+ tag = np.full(len(lat), SEAS_F, np.int8)
+ tag[summer < winter / P["dry_ratio_s"]] = SEAS_S
+ w = winter < summer / P["dry_ratio_w"]
+ tag[w] = SEAS_W
+ tag[w & (tmin >= 18.0) & (p_ann >= P["monsoon_min_mm"])] = SEAS_M
+ return tag
+
+
+def lithology(g, z, continental, age, d_over, seed):
+ nz = fbm(g.xyz, seed + 51, 4, 5.0)
+ lit = np.full(g.n, LI_GRANITE, np.int8)
+ lit[continental & (z < 600) & (nz > 0.1)] = LI_SANDSTONE
+ lit[continental & (z < 300) & (nz < -0.1)] = LI_LIMESTONE
+ lit[np.isin(age, [POST_OROGEN, PRE_OROGEN]) & (z > 2000)] = LI_METAMORPHIC
+ lit[~continental] = LI_OCEAN
+ lit[(d_over > 100) & (d_over < 320)] = LI_ANDESITE
+ lit[np.isin(age, [LIP, VOLCANO, SCAR])] = LI_BASALT
+ return lit
+
+
+def landform(g, z, age, lit, d_over, p_ann, ocean=None, relief_km=100.0):
+ hi, lo = np.asarray(z, dtype=np.float64), np.asarray(z, dtype=np.float64)
+ for _ in range(max(1, int(round(relief_km / g.spacing_km)))): # window ≈ relief_km at any resolution
+ hi, lo = nbr_max(g, hi), nbr_min(g, lo)
+ relief = hi - lo
+ lf = np.full(g.n, LF_PLAIN, np.int8)
+ lf[relief >= 300] = LF_HILLS
+ lf[(z > 1000) & (relief < 500)] = LF_PLATEAU
+ lf[(relief >= 1500) | (z > 2500)] = LF_MOUNTAINS
+ lf[(p_ann < 250) & (relief < 300) & (lit == LI_SANDSTONE)] = LF_DUNES
+ lf[(p_ann >= 250) & (p_ann < 500) & (relief >= 200) & (relief < 800) & (lit == LI_SANDSTONE)] = LF_BADLANDS
+ lf[age == RIFT] = LF_RIFT
+ lf[age == LIP] = LF_BASALT_PLATEAU
+ ocean = z <= 0 if ocean is None else ocean
+ lf[(d_over > 100) & (d_over < 320) & ~ocean] = LF_ARC
+ lf[age == VOLCANO] = LF_MASSIF
+ lf[age == SCAR] = LF_ESCARPMENT
+ lf[ocean] = LF_OCEAN
+ return lf, relief
+
+
+def ground(g, z, lit, t_mean, t_min, p_ann, dist_ocean, river, strahler, discharge, lake, salt_flat, P, ocean=None):
+ land = ~(z <= 0 if ocean is None else ocean)
+ _, slope, _ = steepest_receivers(g, z)
+ flat = slope < P["flat_m_per_km"]
+ coastal = land & (dist_ocean < P["coastal_km"])
+ wet = land & flat & ~lake & (p_ann > P["wet_min_mm"]) & (discharge > P["small_river_km3_yr"])
+ gr = np.zeros(g.n, np.int8)
+ gr[land & (t_mean < -2.0)] = GR_PERMAFROST
+ gr[land & (lit == LI_LIMESTONE) & (p_ann > P["karst_min_mm"])] = GR_KARST
+ gr[wet & (t_mean < 5.0)] = GR_BOG
+ gr[wet & (t_mean >= 5.0)] = GR_WETLAND
+ gr[river & (strahler >= 3) & flat] = GR_FLOODPLAIN
+ gr[coastal & flat & (t_min >= 20.0) & (p_ann > 1000.0)] = GR_MANGROVE
+ gr[river & (strahler >= 4) & coastal] = GR_DELTA
+ gr[salt_flat] = GR_SALT_FLAT
+ if not P["life"]:
+ gr[np.isin(gr, [GR_WETLAND, GR_BOG, GR_MANGROVE])] = GR_NONE
+ return gr
+
+
+def run(ctx) -> dict:
+ g = ctx.grid
+ P = params(ctx.cfg, "environment", DEFAULTS)
+ (z, cont, age, d_over, t_mean, t_min, p_ann, p_jun, p_dec, bio, dist_ocean, river, strahler, q, lake,
+ salt) = ctx.need("elevation_eroded_m", "continental", "age_class", "d_over_km", "T_mean", "T_min", "P_ann",
+ "P_jun", "P_dec", "biotemp", "dist_ocean_km", "river", "strahler", "discharge_km3_yr",
+ "lake", "salt_flat")
+ z = z.astype(np.float64)
+ bed = z
+ if "lake_level_m" in ctx.data: # the ground's shape: lakes at their water, not their beds
+ lev = np.asarray(ctx.data["lake_level_m"], dtype=np.float64)
+ z = np.where(np.isfinite(lev), np.maximum(z, lev), z)
+ ocean = np.asarray(ctx.data["ocean"]) if "ocean" in ctx.data else z <= 0
+ zone, region = holdridge(bio, p_ann, t_min)
+ lit = lithology(g, z, cont, age, d_over, ctx.seed)
+ lf, relief = landform(g, z, age, lit, d_over, p_ann, ocean, P["relief_km"])
+ gr = ground(g, z, lit, t_mean, t_min, p_ann, dist_ocean, river, strahler, q, lake, salt, P, ocean)
+ coal = (lit == LI_SANDSTONE) & (p_ann > 600) & ~ocean & bool(P["life"])
+ get = lambda k, v: np.asarray(ctx.data[k]) if k in ctx.data else np.full(g.n, v)
+ deposits, main = MN.place(g, {"land": ~ocean & ~lake, "z": bed, "lit": lit, "age": age, "lf": lf, "relief": relief,
+ "t_mean": t_mean, "p_ann": p_ann, "d_coll": get("d_coll_km", np.inf),
+ "salt": salt, "endo": get("endorheic", False), "dist_ocean": dist_ocean,
+ "coal": coal}, ctx.seed)
+ iron = np.uint32(MN.BIT["bog_iron"] | MN.BIT["ironstone"] | MN.BIT["iron_high"])
+ return {"deposits": deposits, "deposit_main": main,"holdridge": zone, "hold_region": region,
+ "seasonality": seasonality(p_jun, p_dec, g.lat, t_min, p_ann, P),
+ "landform": lf, "relief_m": relief, "lithology": lit, "ground": gr,
+ "coal_potential": coal, "iron_potential": (deposits & iron) > 0}
diff --git a/mapgen/eras.py b/mapgen/eras.py
new file mode 100644
index 0000000..9b0dbc4
--- /dev/null
+++ b/mapgen/eras.py
@@ -0,0 +1,219 @@
+"""Eras: the base world plus local events. build_era applies an era's events
+to the built base, re-runs the stages they affect inside an influence mask (changed cells + mask_km) and keeps every
+cell outside it exactly as the base. Results: out/r<res>/eras/<era>/ (as out/r<res>/), previews in
+previews/r<res>/eras/<era>/. An era's cache key is the base world's inputs key plus its events (config/eras.toml). Each era's events happen at the end of the era before it; its `years` erode what the events so far
+changed; zone events follow the land at the start of their own era."""
+from __future__ import annotations
+
+import hashlib
+import importlib
+import json
+import re
+import shutil
+from pathlib import Path
+
+import numpy as np
+
+from . import config as C, environment as EN, events as EV, fields as FL, pipeline as P
+from .config import params
+from .erosion import DEFAULTS as EROSION_DEFAULTS, K_MULT, erode
+from .graph import distance_to, ocean_mask
+
+DEFAULTS = {"mask_km": 1500.0, "climate_blend_km": 300.0, "erosion_steps": 1, "years": 10000.0}
+RERUN = ("climate", "hydrology", "seabed", "environment", "ice", "fields")
+
+
+def era_dir(root: Path, res: int, name: str) -> Path:
+ return Path(root) / "out" / f"r{res}" / "eras" / name
+
+
+def steps(tect: dict, name: str) -> list:
+ """[(era, its own events, years)] for every era up to and including `name` ([eras] order): an era's events happen
+ at the end of the era before it; `years` is the time from them to the era's map (None: the default)."""
+ C.era_events(tect, name) # an unknown era: ConfigError
+ eras = tect.get("eras") or {}
+ order = eras["order"]
+ by_name = {e["name"]: e for e in tect.get("event", [])}
+ return [(n, [by_name[e] for e in eras.get(n, {}).get("events", [])], eras.get(n, {}).get("years"))
+ for n in order[: order.index(name) + 1]]
+
+
+def fingerprint(base_key: str, steps_: list) -> str:
+ content = [[own, yrs] for _, own, yrs in steps_]
+ return hashlib.sha256((base_key + json.dumps(content, sort_keys=True)).encode()).hexdigest()[:16]
+
+
+def _same(a, b) -> bool:
+ """Equal arrays; NaN equals NaN in float arrays (a field may hold NaN where it has no value)."""
+ a, b = np.asarray(a), np.asarray(b)
+ return np.array_equal(a, b, equal_nan=a.dtype.kind in "fc" and b.dtype.kind in "fc")
+
+
+def merge_lake_ids(base, new, mask):
+ """Lake ids of an era: the base's outside the mask; inside it a lake the era left as it was keeps its base id and
+ every other lake gets a fresh id after the base's, so one id never names two lakes."""
+ base, new, mask = np.asarray(base), np.asarray(new), np.asarray(mask, bool)
+ out = base.copy()
+ nxt = max(int(base.max()) + 1, 0) if len(base) else 0
+ counts = np.bincount(base[base >= 0]) if (base >= 0).any() else np.zeros(0, np.int64)
+ ks = np.unique(new[mask & (new >= 0)])
+ idx = np.flatnonzero(np.isin(new, ks))
+ idx = idx[np.argsort(new[idx], kind="stable")]
+ starts = np.searchsorted(new[idx], ks)
+ ends = np.append(starts[1:], len(idx))
+ for s, e in zip(starts.tolist(), ends.tolist()):
+ cells = idx[s:e]
+ b = base[cells]
+ kept = b[0] >= 0 and bool(np.all(b == b[0])) and counts[b[0]] == len(cells)
+ into = cells[mask[cells]]
+ if kept:
+ out[into] = b[0]
+ else:
+ out[into] = nxt
+ nxt += 1
+ out[mask & (new < 0)] = -1
+ return out.astype(base.dtype)
+
+
+def zone_key(name: str) -> str:
+ return "zone_" + re.sub(r"[^A-Za-z0-9]", "_", name)
+
+
+def _heights(g, z, events, min_sea, log, name, uncut=lambda z: z):
+ for ev in events: # heights first, in era order
+ if ev["kind"] == "disintegrate":
+ z, z_ref = EV.disintegrate(g.xyz, z, ~ocean_mask(g, uncut(z), min_sea), ev, g.radius_km)
+ if z_ref is None:
+ log(f"era {name}: warning: {ev['name']} has no land in its rim ring (0.9–1.0 × radius): "
+ f"its bowl hangs from 0 m")
+ else:
+ log(f"era {name}: {ev['name']} (ground at its rim {z_ref:.0f} m)")
+ elif ev["kind"] == "volcano":
+ z = EV.volcano(g.xyz, z, ev, g.radius_km)
+ return z
+
+
+def build_era(root: Path, res: int, name: str, log=print, base=None, low_memory: bool = False) -> Path:
+ root = Path(root)
+ cfg, tect = C.load(root)
+ events = C.era_events(tect, name)
+ if not events:
+ log(f"era {name}: the base world (out/r{res})")
+ return root / "out" / f"r{res}"
+ out = era_dir(root, res, name)
+ base_key = P.inputs_key(root, res)
+ plan = steps(tect, name)
+ key = fingerprint(base_key, plan)
+ label = tect["eras"][name].get("label", name)
+ meta_path = out / "cells_meta.json"
+ if meta_path.exists() and (out / "cells.npz").exists():
+ meta = json.loads(meta_path.read_text())
+ era = meta.get("era", {})
+ if era.get("fingerprint") == key:
+ if (era.get("label"), era.get("name")) != (label, name): # a new label or name needs no rebuild
+ era["label"], era["name"] = label, name
+ meta_path.write_text(json.dumps(meta, indent=1))
+ log(f"era {name}: cached")
+ return out
+ ctx = base if base is not None else P.build(root, res, stop="fields", log=lambda m: None, low_memory=low_memory)
+ low_memory = low_memory or ctx.low_memory
+ g, d = ctx.grid, ctx.data
+ E = params(ctx.cfg, "eras", DEFAULTS)
+ EP = {**params(ctx.cfg, "erosion", EROSION_DEFAULTS), "steps": E["erosion_steps"]}
+ kmult = np.vectorize(K_MULT.get)(np.asarray(d["age_class"])).astype(np.float64)
+ z0 = np.asarray(d["elevation_eroded_m"], dtype=np.float64)
+ z, ocean = z0.copy(), np.asarray(d["ocean"])
+ water = np.asarray(d.get("open_water", ocean))
+ cut = np.asarray(d.get("lake_cut_m", np.zeros(g.n)), dtype=np.float64)
+ uncut = lambda zz: np.where(zz == z0, zz + cut, zz) # sea masks: the ground before lake beds were carved
+ zones, lands = [], []
+ for _, own, yrs in plan: # era by era: its events at the end of the era before
+ for ev in own: # zones follow the land before their own era's events
+ if ev["kind"] == "zone":
+ zones.append(ev)
+ lands.append(EV.landmass(g, ~water, ev["seed"], ev["name"]) if ev["shape"] == "landmass" else None)
+ z = _heights(g, z, own, EP["min_sea_km2"], log, name, uncut)
+ hit = z != z0
+ if hit.any(): # the changed ground weathers for the era's years
+ years = float(E["years"] if yrs is None else yrs)
+ ze = erode(g, z, kmult, {**EP, "dt_myr": years / 1.0e6 / E["erosion_steps"]})
+ z = np.where(hit, np.maximum(np.minimum(z, ze), -11000.0), z)
+ ocean, water = ocean_mask(g, uncut(z), np.inf), ocean_mask(g, uncut(z), EP["min_sea_km2"])
+ hit = z != z0
+ weights = [EV.zone_weight(g.xyz, ev, g.radius_km, ctx.seed, None if land is None else g.xyz[land])
+ for ev, land in zip(zones, lands)]
+ changed = hit | (ocean != np.asarray(d["ocean"])) | (water != np.asarray(d.get("open_water", d["ocean"])))
+ for w in weights:
+ changed |= w > 0
+ mask = distance_to(g, changed) <= E["mask_km"] if changed.any() else np.zeros(g.n, bool)
+
+ ec = P.Ctx(ctx.root, ctx.cfg, ctx.tect, ctx.res, dict(d), g, low_memory=low_memory)
+ ec.data.update({"elevation_eroded_m": z.astype(np.asarray(d["elevation_eroded_m"]).dtype), "ocean": ocean,
+ "open_water": water, "lake_carve": hit}) # lake beds: geology, carved where the ground moved
+ new_keys = {}
+ for s in RERUN:
+ o = importlib.import_module(f"mapgen.{s}").run(ec)
+ ec.data.update(o)
+ new_keys[s] = list(o)
+ blend = np.clip(distance_to(g, ~mask) / E["climate_blend_km"], 0.0, 1.0) if (~mask).any() else np.ones(g.n)
+ shape = lambda a, v: v.reshape(-1, *([1] * (a.ndim - 1)))
+ merged = dict(d)
+ for s, keys in new_keys.items():
+ for k in keys:
+ nv, bv = np.asarray(ec.data[k]), np.asarray(d[k])
+ if s == "climate" and nv.dtype.kind == "f": # solved on the whole world, blended in over 300 km
+ nv = (bv + shape(nv, blend) * (nv - bv)).astype(nv.dtype)
+ merged[k] = np.where(shape(nv, mask), nv, bv)
+ for k in ("deposits", "deposit_main", "iron_potential"): # ore is the base's geology: events move ground, the
+ if k in d: # rank-by-share placement would shift it world-wide
+ merged[k] = d[k]
+ if "lake_id" in merged: # one id never names two lakes
+ merged["lake_id"] = merge_lake_ids(d["lake_id"], ec.data["lake_id"], mask)
+ merged["elevation_eroded_m"] = np.where(mask, ec.data["elevation_eroded_m"], d["elevation_eroded_m"]) # events and
+ merged["ocean"] = ocean # lake beds: inside only
+ if "open_water" in d:
+ merged["open_water"] = water
+ H = float(ctx.cfg["planet"]["scale_height_km"]) * 1000.0
+ for ev, w, land in zip(zones, weights, lands): # zone events write their fields last
+ merged.update(EV.apply_zone(merged, w, ev, merged["z_surface_m"], H))
+ merged[zone_key(ev["name"])] = w.astype(np.float32)
+ if land is not None:
+ merged[zone_key(ev["name"]) + "_land"] = land
+ if zones: # plants settle into the zone's air and gravity
+ pl = ctx.cfg["planet"]
+ merged["plant_height_x"] = FL.plant_height(pl["gravity_g"], merged["gravity_g"]).astype(
+ np.asarray(d["plant_height_x"]).dtype)
+ sea_p = np.asarray(merged["pressure_bar"], dtype=np.float64) / FL.pressure(1.0, merged["z_surface_m"], H)
+ wet = np.sqrt(sea_p / pl["sea_level_pressure_bar"]) # more CO₂ per breath: less water lost per growth
+ hz, hr = EN.holdridge(merged["biotemp"], np.asarray(merged["P_ann"], dtype=np.float64) * wet, merged["T_min"])
+ inside = np.logical_or.reduce([w > 0 for w in weights])
+ merged["holdridge"] = np.where(inside, hz, merged["holdridge"]).astype(np.asarray(d["holdridge"]).dtype)
+ merged["hold_region"] = np.where(inside, hr, merged["hold_region"]).astype(np.asarray(d["hold_region"]).dtype)
+ merged["era_mask"] = mask
+ for k, a in d.items(): # guard: nothing outside the mask may differ
+ a, b = np.asarray(a), np.asarray(merged[k])
+ if a.shape[:1] == (g.n,) and not _same(a[~mask], b[~mask]):
+ raise RuntimeError(f"era {name}: {k} changed outside the influence mask")
+
+ rc = P.Ctx(ctx.root, ctx.cfg, ctx.tect, ctx.res, merged, g, out_dir=out,
+ preview_dir=root / "previews" / f"r{res}" / "eras" / name, low_memory=low_memory)
+ importlib.import_module("mapgen.render").run(rc)
+ meta = json.loads(meta_path.read_text())
+ meta["era"] = {"name": name, "label": label, "events": events, "fingerprint": key, "base_key": base_key,
+ "steps": [[n, [e["name"] for e in own], yrs] for n, own, yrs in plan],
+ "mask_km": E["mask_km"], "mask_cells": int(mask.sum())}
+ meta_path.write_text(json.dumps(meta, indent=1))
+ log(f"era {name}: {int(mask.sum())} of {g.n} cells inside the influence mask")
+ return out
+
+
+def build_all(root: Path, res: int, log=print, base=None, low_memory: bool = False) -> list:
+ """Every configured era with events (the base era is the base build); era folders no longer configured go."""
+ _, tect = C.load(Path(root))
+ names = [n for n in (tect.get("eras") or {}).get("order", []) if C.era_events(tect, n)]
+ built = [build_era(root, res, n, log, base, low_memory) for n in names]
+ top = Path(root) / "out" / f"r{res}" / "eras"
+ for p in (top.iterdir() if top.is_dir() else []):
+ if p.is_dir() and p.name not in names:
+ shutil.rmtree(p, ignore_errors=True)
+ return built
diff --git a/mapgen/erosion.py b/mapgen/erosion.py
new file mode 100644
index 0000000..5384988
--- /dev/null
+++ b/mapgen/erosion.py
@@ -0,0 +1,54 @@
+"""Stage `erosion`: implicit stream-power incision (Braun & Willett 2013) + km-scale hillslope smoothing."""
+from __future__ import annotations
+
+import numpy as np
+
+from .config import params
+from .crust import CRATON, LIP, OCEANIC, POST_OROGEN, PRE_OROGEN, RIFT, SCAR, VOLCANO
+from .elevation import solve_sea_level_connected
+from .graph import (accumulate, drop_smooth_cache, ocean_mask, priority_flood, receiver_levels, smooth_km,
+ steepest_receivers)
+
+DEFAULTS = {"min_sea_km2": 5.0e6, "steps": 12, "dt_myr": 5.0, "k": 0.02, "m": 0.5, "hillslope_km": 25.0, "sediment_fill": 0.15}
+K_MULT = {OCEANIC: 0.0, CRATON: 1.5, PRE_OROGEN: 1.2, POST_OROGEN: 1.0, RIFT: 1.0, LIP: 0.6, SCAR: 0.9, VOLCANO: 0.8}
+
+
+def erode(g, z, kmult, P):
+ z = np.asarray(z, dtype=np.float64).copy()
+ ar = np.arange(g.n)
+ for _ in range(int(P["steps"])):
+ ocean = ocean_mask(g, z, P["min_sea_km2"])
+ zb = np.where(ocean, 0.0, z) # base level = sea level, not the seafloor
+ zf = priority_flood(g, zb, ocean)
+ zb = np.where(ocean, zb, zb + P["sediment_fill"] * (zf - zb)) # sediment infills closed basins
+ recv, _, dist = steepest_receivers(g, zf)
+ recv = np.where(ocean, ar, recv)
+ levels = receiver_levels(recv)
+ area = accumulate(recv, levels, g.area_km2)
+ F = P["k"] * kmult * P["dt_myr"] * area ** P["m"] / np.where(np.isfinite(dist), dist, 1.0)
+ F[ocean] = 0.0
+ znew = zb.copy()
+ for lv in levels[1:]:
+ r = recv[lv]
+ znew[lv] = np.minimum(zb[lv], (zb[lv] + F[lv] * znew[r]) / (1.0 + F[lv]))
+ znew = smooth_km(g, znew, P["hillslope_km"], keep=True) # hillslope/sub-grid smoothing, fixed length in km
+ z = np.where(ocean, z, znew)
+ drop_smooth_cache(g)
+ return z
+
+
+def run(ctx) -> dict:
+ g = ctx.grid
+ P = params(ctx.cfg, "erosion", DEFAULTS)
+ z0, age = ctx.need("elevation_m", "age_class")
+ kmult = np.vectorize(K_MULT.get)(age).astype(np.float64)
+ z0 = np.asarray(z0, dtype=np.float64)
+ added = np.asarray(ctx.data.get("land_added", np.zeros(g.n, bool)), bool) # land the sea-level solve didn't
+ target = ctx.cfg["build"]["land_fraction"] + g.area_km2[added & (z0 > 0)].sum() / g.area_km2.sum() # see
+ z = erode(g, z0, kmult, P)
+ z = solve_sea_level_connected(g, z, target, P["min_sea_km2"])
+ z = np.maximum(z, -11000.0)
+ # the sea is the connected world ocean; a separate basin ≥ min_sea_km2 shaped the coasts above as sea and stays
+ # open water for the climate (open_water), but its water is a lake (hydrology), not sea
+ return {"elevation_eroded_m": z.astype(np.float32), "ocean": ocean_mask(g, z, np.inf),
+ "open_water": ocean_mask(g, z, P["min_sea_km2"])}
diff --git a/mapgen/events.py b/mapgen/events.py
new file mode 100644
index 0000000..cba874e
--- /dev/null
+++ b/mapgen/events.py
@@ -0,0 +1,87 @@
+"""Era events: pure functions of positions and the event's config, so the era
+builder (world cells) and the refinement (fine cells) apply the same event."""
+from __future__ import annotations
+
+import numpy as np
+from scipy.spatial import cKDTree
+
+from .fields import pressure
+from .graph import components
+from .noise import fbm, name_seed
+from .pipeline import StageError
+from .sphere import gc_dist_km, latlon_to_xyz
+from .zones import smootherstep
+
+
+def disintegrate(xyz, z, land, ev: dict, radius_km: float):
+ """(new heights, z_ref): inside radius_km of the centre min(old, z_ref − depth · sqrt(1 − (d/r)²)); z_ref = mean
+ ground height of the land in the ring 0.9–1.0 r before the event, None when no land lies in that ring (the bowl
+ then hangs from 0 m). Material vanishes: no ejecta, no rim."""
+ z = np.asarray(z, dtype=np.float64)
+ d = gc_dist_km(np.asarray(xyz, dtype=np.float64), latlon_to_xyz(*ev["center"]), radius_km)
+ r = float(ev["radius_km"])
+ ring = np.asarray(land, bool) & (d >= 0.9 * r) & (d <= r)
+ z_ref = float(np.mean(z[ring])) if ring.any() else None # None: no land on its rim (the bowl hangs from 0 m)
+ bowl = (0.0 if z_ref is None else z_ref) - ev["depth_m"] * np.sqrt(np.clip(1.0 - (d / r) ** 2, 0.0, 1.0))
+ return np.where(d < r, np.minimum(z, bowl), z), z_ref
+
+
+def volcano(xyz, z, ev: dict, radius_km: float):
+ """Heights after a volcano (only raises): cone, shield or caldera; peak_m above the old ground (peak_mode "above")
+ or as an absolute height ("absolute")."""
+ z = np.asarray(z, dtype=np.float64)
+ d = gc_dist_km(np.asarray(xyz, dtype=np.float64), latlon_to_xyz(*ev["center"]), radius_km)
+ x = np.clip(d / ev["radius_km"], 0.0, 1.0)
+ prof = {"cone": (1.0 - x) ** 1.5, "shield": (1.0 - x * x) ** 2,
+ "caldera": np.maximum((1.0 - x) ** 1.5 - 0.6 * np.exp(-(x / 0.15) ** 2), 0.0)}[ev.get("shape", "cone")]
+ peak = float(ev["peak_m"])
+ new = z + peak * prof if ev.get("peak_mode", "above") == "above" else z + np.maximum(peak - z, 0.0) * prof
+ return np.maximum(z, new)
+
+
+def landmass(g, land, seed_latlon, event_name: str):
+ """The connected land (grid cells) containing the seed point."""
+ lab = components(g, land)
+ i = g.cell_index(*seed_latlon)
+ if lab[i] < 0:
+ land = np.flatnonzero(np.asarray(land, bool))
+ hint = ""
+ if len(land):
+ j = land[np.argmax(g.xyz[land] @ g.xyz[i])]
+ hint = f"; the nearest land is at [{g.lat[j]:.1f}, {g.lon[j]:.1f}]"
+ raise StageError(f"event {event_name}: its seed {list(seed_latlon)} lies in the sea in this era{hint} "
+ f"(move the seed in config/eras.toml)")
+ return lab == lab[i]
+
+
+def zone_weight(xyz, ev: dict, radius_km: float, seed: int, land_xyz=None):
+ """0–1 per point: 1 inside, 0 outside; a linear ramp edge_km wide at the edge (sharp), or the smooth profile.
+ circle: centre + radius_km. landmass: within reach of the landmass cells land_xyz (unit vectors), the reach
+ varying between reach_km by low-frequency noise."""
+ xyz = np.asarray(xyz, dtype=np.float64)
+ if ev["shape"] == "circle":
+ d = gc_dist_km(xyz, latlon_to_xyz(*ev["center"]), radius_km)
+ reach = np.full(len(xyz), float(ev["radius_km"]))
+ else:
+ chord, _ = cKDTree(np.asarray(land_xyz, dtype=np.float64)).query(xyz)
+ d = 2.0 * np.arcsin(np.clip(chord / 2.0, 0.0, 1.0)) * radius_km
+ lo, hi = ev["reach_km"]
+ reach = lo + (hi - lo) * np.clip(0.5 + 1.5 * fbm(xyz, name_seed(seed, ev["name"]), 3, 1.5), 0.0, 1.0)
+ if "edge_km" in ev:
+ return np.clip((reach - d) / ev["edge_km"], 0.0, 1.0)
+ return 1.0 - smootherstep(d / reach)
+
+
+def apply_zone(fields: dict, w, ev: dict, z_surface_m, scale_height_m: float) -> dict:
+ """Fields inside a zone event: absolute values blended by w (gravity_g, o2_fraction, fire_reactivity; pressure_bar
+ is the sea-level value, falling with height as elsewhere); po2_bar follows. Never height. Cells with w = 0 keep
+ their values exactly."""
+ w = np.asarray(w, dtype=np.float64)
+ decay = pressure(1.0, z_surface_m, scale_height_m)
+ new = {}
+ for k, v in ev["fields"].items():
+ base = np.asarray(fields[k], dtype=np.float64)
+ new[k] = (base / decay * (1.0 - w) + v * w) * decay if k == "pressure_bar" else base * (1.0 - w) + v * w
+ o2 = new.get("o2_fraction", np.asarray(fields["o2_fraction"], dtype=np.float64))
+ new["po2_bar"] = o2 * new.get("pressure_bar", np.asarray(fields["pressure_bar"], dtype=np.float64))
+ return {k: np.where(w > 0, v, fields[k]).astype(np.asarray(fields[k]).dtype) for k, v in new.items()}
diff --git a/mapgen/fields.py b/mapgen/fields.py
new file mode 100644
index 0000000..bf83d3f
--- /dev/null
+++ b/mapgen/fields.py
@@ -0,0 +1,36 @@
+"""Stage `fields` and shared zone modifiers."""
+from __future__ import annotations
+
+import numpy as np
+
+from .config import MASK_DEFAULTS, params
+
+
+def gravity_mod(cfg, m_gravity):
+ M = params(cfg, "masks", MASK_DEFAULTS)
+ return np.clip(1.0 + M["gravity_range"] * np.asarray(m_gravity, dtype=np.float64), 0.05, None)
+
+
+def o2_mod(cfg, m_o2):
+ M = params(cfg, "masks", MASK_DEFAULTS)
+ return np.clip(1.0 + M["o2_range"] * np.asarray(m_o2, dtype=np.float64), 0.0, None)
+
+
+def pressure(p0_bar, z_m, scale_height_m):
+ """Air pressure at ground height z (bar): sea-level pressure p0 falling with height (below 0 m: p0)."""
+ return p0_bar * np.exp(-np.maximum(np.asarray(z_m, dtype=np.float64), 0.0) / scale_height_m)
+
+
+def plant_height(g_planet, gravity_g):
+ """How much taller plants grow than under the planet's normal gravity (height limit ∝ 1 / g)."""
+ return g_planet / np.asarray(gravity_g, dtype=np.float64)
+
+
+def run(ctx) -> dict:
+ pl = ctx.cfg["planet"]
+ zs, m_o2, m_g = ctx.need("z_surface_m", "m_o2_zones", "m_gravity_zones")
+ p = pressure(pl["sea_level_pressure_bar"], zs, pl["scale_height_km"] * 1000.0)
+ o2 = pl["o2_fraction"] * o2_mod(ctx.cfg, m_o2)
+ return {"po2_bar": o2 * p, "gravity_g": pl["gravity_g"] * gravity_mod(ctx.cfg, m_g), "pressure_bar": p,
+ "o2_fraction": o2, "fire_reactivity": np.ones(len(p)), # zone events (eras) set other values
+ "plant_height_x": plant_height(pl["gravity_g"], pl["gravity_g"] * gravity_mod(ctx.cfg, m_g))}
diff --git a/mapgen/geo.py b/mapgen/geo.py
new file mode 100644
index 0000000..152ae66
--- /dev/null
+++ b/mapgen/geo.py
@@ -0,0 +1,200 @@
+"""GeoJSON exports: rivers, coastlines/lakes (marching squares), plate boundaries."""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import h3.api.basic_int as h3
+import numpy as np
+
+BOUNDARY_NAMES = {1: "convergent", 2: "divergent", 3: "transform"}
+# marching-squares cases; corner bits TL=8 TR=4 BR=2 BL=1; edges T R B L
+_CASES = {1: [("L", "B")], 2: [("B", "R")], 3: [("L", "R")], 4: [("T", "R")], 5: [("T", "R"), ("L", "B")],
+ 6: [("T", "B")], 7: [("L", "T")], 8: [("L", "T")], 9: [("T", "B")], 10: [("L", "T"), ("B", "R")],
+ 11: [("T", "R")], 12: [("L", "R")], 13: [("B", "R")], 14: [("L", "B")]}
+_EDGE = {"T": (1, 0), "R": (2, 1), "B": (1, 2), "L": (0, 1)} # doubled (dx, dy) inside a 2x2 block
+
+
+def split_antimeridian(coords):
+ parts, cur = [], [list(coords[0])]
+ for a, b in zip(coords, coords[1:]):
+ if abs(b[0] - a[0]) > 180.0:
+ parts.append(cur)
+ cur = []
+ cur.append(list(b))
+ parts.append(cur)
+ return [p for p in parts if len(p) >= 2]
+
+
+def _feature(geom_type, coords, props):
+ return {"type": "Feature", "geometry": {"type": geom_type, "coordinates": coords}, "properties": props}
+
+
+def river_lines(g, recv, river, strahler, discharge):
+ """One LineString per run of equal Strahler order (runs share their junction vertex)."""
+ n = g.n
+ ar = np.arange(n)
+ has_donor = np.zeros(n, bool)
+ nr = river & (recv != ar)
+ has_donor[recv[nr]] = True
+ visited = np.zeros(n, bool)
+ feats = []
+ for s in np.flatnonzero(river & ~has_donor):
+ cells, c = [int(s)], int(s)
+ while True:
+ visited[c] = True
+ r = int(recv[c])
+ if r == c:
+ break
+ cells.append(r)
+ if not river[r] or visited[r]:
+ break
+ c = r
+ body = cells[:-1] if len(cells) > 1 else cells
+ start = 0
+ for i in range(1, len(body) + 1):
+ if i == len(body) or strahler[body[i]] != strahler[body[i - 1]]:
+ run = body[start:i] + [cells[i] if i < len(cells) else body[-1]]
+ coords = [[round(float(g.lon[k]), 4), round(float(g.lat[k]), 4)] for k in run]
+ props = {"order": int(strahler[body[start]]), "discharge_km3_yr": round(float(discharge[body[i - 1]]), 3)}
+ feats += [_feature("LineString", p, props) for p in split_antimeridian(coords)]
+ start = i
+ return feats
+
+
+def boundary_features(g, plate, bnd_type):
+ s, d = g.src, g.dst
+ e = np.flatnonzero((plate[s] != plate[d]) & (s < d))
+ lines = {}
+ for k in e:
+ a, b = int(g.ids[s[k]]), int(g.ids[d[k]])
+ seg = [[round(lng, 4), round(lat, 4)] for lat, lng in h3.directed_edge_to_boundary(h3.cells_to_directed_edge(a, b))]
+ if abs(seg[0][0] - seg[-1][0]) > 180:
+ continue
+ lines.setdefault(BOUNDARY_NAMES.get(int(bnd_type[s[k]]), "transform"), []).append(seg)
+ return [_feature("MultiLineString", v, {"type": t}) for t, v in sorted(lines.items())]
+
+
+def contours(mask):
+ m = np.pad(np.asarray(mask, dtype=np.uint8), 1)
+ code = m[:-1, :-1] * 8 + m[:-1, 1:] * 4 + m[1:, 1:] * 2 + m[1:, :-1]
+ adj = {}
+ for case, segs in _CASES.items():
+ ys, xs = np.nonzero(code == case)
+ for (e1, e2) in segs:
+ for y, x in zip(ys.tolist(), xs.tolist()):
+ p = (2 * x + _EDGE[e1][0], 2 * y + _EDGE[e1][1])
+ q = (2 * x + _EDGE[e2][0], 2 * y + _EDGE[e2][1])
+ adj.setdefault(p, []).append(q)
+ adj.setdefault(q, []).append(p)
+ seen, rings = set(), []
+ for start in adj:
+ if start in seen:
+ continue
+ ring, prev, cur = [start], None, start
+ seen.add(start)
+ while True:
+ nxt = [q for q in adj[cur] if q != prev]
+ if not nxt:
+ break
+ prev, cur = cur, nxt[0]
+ ring.append(cur)
+ if cur == start:
+ break
+ seen.add(cur)
+ rings.append([(px / 2.0 - 1.0, py / 2.0 - 1.0) for px, py in ring])
+ return rings
+
+
+def _signed_area(ring):
+ a = np.asarray(ring, dtype=np.float64)
+ return 0.5 * float(np.sum(a[:-1, 0] * a[1:, 1] - a[1:, 0] * a[:-1, 1]))
+
+
+def _point_in_ring(pt, ring):
+ a = np.asarray(ring, dtype=np.float64)
+ x1, y1, x2, y2 = a[:-1, 0], a[:-1, 1], a[1:, 0], a[1:, 1]
+ cross = (y1 > pt[1]) != (y2 > pt[1])
+ xi = x1 + (pt[1] - y1) * (x2 - x1) / np.where(y2 != y1, y2 - y1, 1e-300)
+ return bool(np.count_nonzero(cross & (pt[0] < xi)) % 2)
+
+
+def _orient(ring, ccw):
+ return ring if (_signed_area(ring) > 0) == ccw else ring[::-1]
+
+
+def _clip(ring, left, x0=180.0):
+ """Sutherland–Hodgman clip of a closed ring to x ≤ x0 (left) or x ≥ x0."""
+ inside = (lambda p: p[0] <= x0) if left else (lambda p: p[0] >= x0)
+ cut = lambda p, q: [x0, p[1] + (x0 - p[0]) / (q[0] - p[0]) * (q[1] - p[1])]
+ pts, out = ring[:-1], []
+ for i in range(len(pts)):
+ cur, prev = pts[i], pts[i - 1]
+ if inside(cur):
+ if not inside(prev):
+ out.append(cut(prev, cur))
+ out.append(cur)
+ elif inside(prev):
+ out.append(cut(prev, cur))
+ return out + [out[0]] if len(out) >= 3 else []
+
+
+def _split_seam(rings):
+ """rings[0] exterior + holes in continuous longitude; split at +180 into ≤2 polygons in [−180, 180]."""
+ if max(p[0] for p in rings[0]) <= 180.0:
+ return [rings]
+ polys = []
+ for left in (True, False):
+ ext = _clip(rings[0], left)
+ if len(ext) < 4:
+ continue
+ holes = [h for h in (_clip(r, left) for r in rings[1:]) if len(h) >= 4]
+ part = [ext] + holes
+ if not left:
+ part = [[[x - 360.0, y] for x, y in r] for r in part]
+ polys.append(part)
+ return polys
+
+
+def _round(poly):
+ return [[[round(x, 4), round(y, 4)] for x, y in r] for r in poly]
+
+
+def contour_features(mask, kind):
+ """Land/lake outlines as RFC 7946 (Multi)Polygons: CCW exteriors, CW holes, split at the antimeridian."""
+ H, W = mask.shape
+ col = int(np.argmin(mask.sum(axis=0)))
+ rings = []
+ for ring in contours(np.roll(mask, -col, axis=1)):
+ pts = [[(x + col + 0.5) / W * 360.0 - 180.0, max(-90.0, min(90.0, 90.0 - (y + 0.5) / H * 180.0))]
+ for x, y in ring]
+ if len(pts) >= 4 and pts[0] == pts[-1]:
+ rings.append(pts)
+ depth = [sum(_point_in_ring(r[0], o) for j, o in enumerate(rings) if j != i) for i, r in enumerate(rings)]
+ feats = []
+ for i, r in enumerate(rings):
+ if depth[i] % 2:
+ continue
+ holes = [_orient(rings[j], False) for j in range(len(rings))
+ if depth[j] == depth[i] + 1 and _point_in_ring(rings[j][0], r)]
+ polys = [_round(p) for p in _split_seam([_orient(r, True)] + holes)]
+ if len(polys) == 1:
+ feats.append(_feature("Polygon", polys[0], {"kind": kind}))
+ elif polys:
+ feats.append(_feature("MultiPolygon", polys, {"kind": kind}))
+ return feats
+
+
+def _write(path: Path, feats) -> None:
+ path.write_text(json.dumps({"type": "FeatureCollection", "features": feats}, separators=(",", ":")))
+
+
+def write_all(ctx, out_dir: Path, land_raster, lake_raster) -> None:
+ g, d = ctx.grid, ctx.data
+ gdir = out_dir / "geo"
+ gdir.mkdir(parents=True, exist_ok=True)
+ _write(gdir / "rivers.geojson", river_lines(g, d["recv"], d["river"], d["strahler"], d["discharge_km3_yr"]))
+ _write(gdir / "plate_boundaries.geojson", boundary_features(g, d["plate"], d["bnd_type"]))
+ step = max(1, land_raster.shape[1] // 2048)
+ _write(gdir / "coast.geojson", contour_features(land_raster[::step, ::step], "land"))
+ _write(gdir / "lakes.geojson", contour_features(lake_raster[::step, ::step] & land_raster[::step, ::step], "lake"))
diff --git a/mapgen/graph.py b/mapgen/graph.py
new file mode 100644
index 0000000..126c71f
--- /dev/null
+++ b/mapgen/graph.py
@@ -0,0 +1,477 @@
+"""Operations on the H3 cell graph (CSR neighbours)."""
+from __future__ import annotations
+
+import heapq
+
+import numpy as np
+from scipy import sparse
+from scipy.sparse import csgraph
+from scipy.sparse import linalg as splinalg
+
+
+def nbr_mean(g, f):
+ return np.bincount(g.src, weights=f[g.dst], minlength=g.n) / g.counts
+
+
+def nbr_max(g, f):
+ out = np.asarray(f, dtype=np.float64).copy()
+ np.maximum.at(out, g.src, f[g.dst])
+ return out
+
+
+def nbr_min(g, f):
+ out = np.asarray(f, dtype=np.float64).copy()
+ np.minimum.at(out, g.src, f[g.dst])
+ return out
+
+
+def diffuse(g, f, iters: int, alpha: float = 0.5, mask=None):
+ f = np.asarray(f, dtype=np.float64)
+ for _ in range(int(iters)):
+ new = (1.0 - alpha) * f + alpha * nbr_mean(g, f)
+ f = new if mask is None else np.where(mask, new, f)
+ return f
+
+
+def laplacian_matrix(g):
+ """Graph Laplacian (per km²): Δf_i ≈ (4/k_i) Σ_j (f_j − f_i)/d_ij² (exact for a regular hex lattice)."""
+ w = 4.0 / (g.counts[g.src] * g.edge_km**2)
+ lap = sparse.csr_matrix((w, (g.src, g.dst)), shape=(g.n, g.n))
+ return lap - sparse.diags(np.asarray(lap.sum(axis=1)).ravel())
+
+
+def workers() -> int:
+ """Threads for independent jobs (seasons, components): WORLDGEN_THREADS, default 3. Each job computes exactly
+ what it would alone, so results never depend on it; numpy and scipy's sparse kernels release the GIL."""
+ import os
+ try:
+ return max(1, int(os.environ.get("WORLDGEN_THREADS", "3")))
+ except ValueError:
+ return 3
+
+
+def pmap(fn, items) -> list:
+ """[fn(x) for x in items] on up to workers() threads, in order."""
+ items = list(items)
+ if workers() <= 1 or len(items) <= 1:
+ return [fn(x) for x in items]
+ from concurrent.futures import ThreadPoolExecutor
+ with ThreadPoolExecutor(min(workers(), len(items))) as ex:
+ return list(ex.map(fn, items))
+
+
+def smooth_km(g, f, length_km: float, rtol: float = 1e-6, keep: bool = False):
+ """Resolution-independent smoothing: solve (I − L²Δ) s = f (screened Poisson, decay length ≈ L km).
+ keep: keep the matrix on g for the next call (loops over one grid; drop_smooth_cache(g) frees it)."""
+ f = np.asarray(f, dtype=np.float64)
+ scale = float(np.max(np.abs(f))) if f.size else 0.0
+ if length_km <= 0 or scale == 0.0:
+ return f.copy()
+ f = f / scale # linear system: solve at unit scale (avoids breakdown)
+ cache = g.__dict__.get("_screened", {})
+ if length_km in cache:
+ A, inv_diag = cache[length_km]
+ else:
+ A = (sparse.identity(g.n, format="csr") - length_km**2 * laplacian_matrix(g)).tocsr()
+ inv_diag = 1.0 / A.diagonal()
+ if keep:
+ g.__dict__.setdefault("_screened", {})[length_km] = (A, inv_diag)
+ s, info = bicgstab_jacobi(A, f, f.copy(), inv_diag, rtol, 5000)
+ if info != 0:
+ raise ValueError(f"smooth_km: solver did not converge (info={info})")
+ return s * scale
+
+
+def _make_bicg_jit():
+ """bicgstab's vector updates fused into single passes (numba optional; WORLDGEN_NO_JIT=1 turns it off). Each
+ element gets the same operations in the same order as scipy's numpy statements; dot products, norms and sparse
+ products stay the very calls scipy makes, so the iterates — and the answer — are the same floats. (Threads were
+ tried and dropped: on a busy machine they wait more than they work.)"""
+ import os
+ if os.environ.get("WORLDGEN_NO_JIT"):
+ return None
+ try:
+ import numba
+ except ImportError:
+ return None
+
+ @numba.njit(cache=True, nogil=True)
+ def p_update(p, v, r, omega, beta): # p -= omega*v; p *= beta; p += r
+ for i in range(len(p)):
+ p[i] = (p[i] - omega * v[i]) * beta + r[i]
+
+ @numba.njit(cache=True, nogil=True)
+ def scale(out, d, x): # out = d * x (the Jacobi preconditioner)
+ for i in range(len(x)):
+ out[i] = d[i] * x[i]
+
+ @numba.njit(cache=True, nogil=True)
+ def axpy_neg(r, a, v): # r -= a*v
+ for i in range(len(r)):
+ r[i] = r[i] - a * v[i]
+
+ @numba.njit(cache=True, nogil=True)
+ def x_update(x, alpha, phat, omega, shat): # x += alpha*phat; x += omega*shat
+ for i in range(len(x)):
+ x[i] = (x[i] + alpha * phat[i]) + omega * shat[i]
+
+ @numba.njit(cache=True, nogil=True)
+ def axpy(x, a, v): # x += a*v
+ for i in range(len(x)):
+ x[i] = x[i] + a * v[i]
+
+ return p_update, scale, axpy_neg, x_update, axpy
+
+
+_bicg_jit = _make_bicg_jit()
+
+
+def _csr_matvec_into(A):
+ """mv(x, y): y = A @ x, by scipy's own kernel into a reused buffer (A @ x zero-fills a fresh array and calls the
+ same csr_matvec; a fresh 16 MB array costs more in page faults than the product)."""
+ try:
+ from scipy.sparse import _sparsetools
+ fn = _sparsetools.csr_matvec
+ except (ImportError, AttributeError):
+ fn = None
+ M, N = A.shape
+
+ def mv(x, y):
+ if fn is None:
+ y[:] = A @ x
+ return
+ y.fill(0.0)
+ fn(M, N, A.indptr, A.indices, A.data, x, y)
+ return mv
+
+
+JIT_MIN_N = 50_000 # smaller systems: scipy (thread start-up outweighs the gain; same answer either way)
+
+
+def bicgstab_jacobi(A, b, x0, inv_diag, rtol, maxiter):
+ """scipy.sparse.linalg.bicgstab(A, b, x0=x0, rtol=rtol, maxiter=maxiter, M=diag(inv_diag)) for a CSR matrix and
+ float64 vectors: the same iterates, statement by statement (scipy 1.12+'s pure-Python loop), with the vector
+ updates fused and everything written into preallocated buffers (memory-bound; fresh 16 MB temporaries cost
+ more in page faults than in arithmetic). Falls back to scipy without numba."""
+ b = np.asarray(b, dtype=np.float64).ravel()
+ if (_bicg_jit is None or A.shape[0] < JIT_MIN_N or not sparse.isspmatrix_csr(A)
+ and not isinstance(A, sparse.csr_array) or A.dtype != np.float64):
+ M = splinalg.LinearOperator(A.shape, matvec=lambda x: inv_diag * x)
+ return splinalg.bicgstab(A, b, x0=x0, rtol=rtol, maxiter=maxiter, M=M)
+ p_update, scale, axpy_neg, x_update, axpy = _bicg_jit
+ mv = _csr_matvec_into(A)
+ inv_diag = np.ascontiguousarray(inv_diag, dtype=np.float64)
+ x = np.array(x0, dtype=np.float64)
+ bnrm2 = np.linalg.norm(b)
+ atol = max(0.0, float(rtol) * float(bnrm2))
+ if bnrm2 == 0:
+ return b, 0
+ rhotol = np.finfo(x.dtype.char).eps ** 2
+ omegatol = rhotol
+ rho_prev, omega, alpha, p, v = None, None, None, None, None
+ r = b - A @ x if x.any() else b.copy()
+ rtilde = r.copy()
+ phat, shat, v, t = np.empty_like(r), np.empty_like(r), np.empty_like(r), np.empty_like(r)
+ for iteration in range(maxiter):
+ if np.linalg.norm(r) < atol:
+ return x, 0
+ rho = np.dot(rtilde, r)
+ if np.abs(rho) < rhotol:
+ return x, -10
+ if iteration > 0:
+ if np.abs(omega) < omegatol:
+ return x, -11
+ beta = (rho / rho_prev) * (alpha / omega)
+ p_update(p, v, r, omega, beta)
+ else:
+ p = r.copy()
+ scale(phat, inv_diag, p)
+ mv(phat, v)
+ rv = np.dot(rtilde, v)
+ if rv == 0:
+ return x, -11
+ alpha = rho / rv
+ axpy_neg(r, alpha, v)
+ s = r # scipy copies r into s here and reads both unchanged until r -= omega*t
+ if np.linalg.norm(s) < atol:
+ axpy(x, alpha, phat)
+ return x, 0
+ scale(shat, inv_diag, s)
+ mv(shat, t)
+ omega = np.dot(t, s) / np.dot(t, t)
+ x_update(x, alpha, phat, omega, shat)
+ axpy_neg(r, omega, t)
+ rho_prev = rho
+ return x, maxiter
+
+
+def drop_smooth_cache(g) -> None:
+ """Free the matrices smooth_km keeps on g."""
+ g.__dict__.pop("_screened", None)
+
+
+def gradient(g, f):
+ """Tangent-plane gradient (f per km): (2/k) Σ_j (f_j − f_i)/d_ij · t_ij."""
+ w = (f[g.dst] - f[g.src]) / g.edge_km
+ s = np.stack([np.bincount(g.src, weights=w * g.edge_tangents[:, c], minlength=g.n) for c in range(3)], axis=1)
+ return s * (2.0 / g.counts)[:, None]
+
+
+def _csr(g, weights):
+ return sparse.csr_matrix((weights, (g.src, g.dst)), shape=(g.n, g.n))
+
+
+def nearest_source(g, sources, weights=None):
+ sources = np.asarray(sources, dtype=np.int64)
+ if len(sources) == 0:
+ return np.full(g.n, np.inf), np.full(g.n, -9999, dtype=np.int64)
+ w = g.edge_km if weights is None else weights
+ dist, _, src = csgraph.dijkstra(_csr(g, w), directed=True, indices=sources,
+ min_only=True, return_predecessors=True)
+ return dist, src.astype(np.int64)
+
+
+def distance_to(g, mask):
+ return nearest_source(g, np.flatnonzero(mask))[0]
+
+
+def priority_flood(g, z, sink_mask, eps: float = 0.01):
+ """Barnes (2014) priority-flood + ε: every non-sink cell gets a strictly descending path to a sink."""
+ sink_mask = np.asarray(sink_mask, dtype=bool)
+ if not sink_mask.any():
+ raise ValueError("priority_flood: no sink cells")
+ has_open = np.bincount(g.src, weights=(~sink_mask)[g.dst].astype(np.float64), minlength=g.n) > 0
+ seeds = np.flatnonzero(sink_mask & has_open)
+ z = np.asarray(z, dtype=np.float64)
+ if _flood_jit is not None:
+ return _flood_jit(z.copy(), sink_mask.copy(), np.asarray(g.nbr_ptr, np.int64), np.asarray(g.nbr_idx, np.int64),
+ seeds.astype(np.int64), float(eps))
+ return _flood_py(z, sink_mask, g.nbr_ptr, g.nbr_idx, seeds, eps)
+
+
+def _flood_py(z, sink_mask, nbr_ptr, nbr_idx, seeds, eps):
+ zf = np.asarray(z, dtype=np.float64).tolist()
+ done = np.asarray(sink_mask).tolist()
+ ptr = np.asarray(nbr_ptr).tolist()
+ idx = np.asarray(nbr_idx).tolist()
+ heap = [(zf[i], i) for i in np.asarray(seeds).tolist()]
+ heapq.heapify(heap)
+ while heap:
+ zc, c = heapq.heappop(heap)
+ for k in range(ptr[c], ptr[c + 1]):
+ n = idx[k]
+ if not done[n]:
+ done[n] = True
+ if zf[n] < zc + eps:
+ zf[n] = zc + eps
+ heapq.heappush(heap, (zf[n], n))
+ return np.array(zf)
+
+
+def _make_flood_jit():
+ """_flood_py compiled with numba when it is installed (optional: same heap order, same float steps, same result;
+ WORLDGEN_NO_JIT=1 turns it off)."""
+ import os
+ if os.environ.get("WORLDGEN_NO_JIT"):
+ return None
+ try:
+ import numba
+ except ImportError:
+ return None
+
+ @numba.njit(cache=True)
+ def flood(zf, done, ptr, idx, seeds, eps):
+ heap = [(zf[i], i) for i in seeds]
+ heapq.heapify(heap)
+ while len(heap):
+ zc, c = heapq.heappop(heap)
+ for k in range(ptr[c], ptr[c + 1]):
+ n = idx[k]
+ if not done[n]:
+ done[n] = True
+ if zf[n] < zc + eps:
+ zf[n] = zc + eps
+ heapq.heappush(heap, (zf[n], n))
+ return zf
+ return flood
+
+
+_flood_jit = _make_flood_jit()
+
+
+def _make_steep_jit():
+ """steepest_receivers' per-row maximum, compiled (numba optional, as the flood): the first edge in row order
+ with the largest slope, slopes computed as numpy does. ok=False (empty row, NaN): use the numpy path."""
+ import os
+ if os.environ.get("WORLDGEN_NO_JIT"):
+ return None
+ try:
+ import numba
+ except ImportError:
+ return None
+
+ @numba.njit(cache=True)
+ def steep(z, ptr, dst, edge):
+ n = len(ptr) - 1
+ first = np.empty(n, np.int64)
+ best = np.empty(n)
+ for i in range(n):
+ a, b = ptr[i], ptr[i + 1]
+ if a == b:
+ return first, best, False
+ bk = a
+ bs = (z[i] - z[dst[a]]) / edge[a]
+ if bs != bs:
+ return first, best, False
+ for k in range(a + 1, b):
+ sk = (z[i] - z[dst[k]]) / edge[k]
+ if sk != sk:
+ return first, best, False
+ if sk > bs:
+ bs, bk = sk, k
+ first[i], best[i] = bk, bs
+ return first, best, True
+ return steep
+
+
+_steep_jit = _make_steep_jit()
+
+
+def steepest_receivers(g, z):
+ """Per cell: the neighbour of steepest descent (first in neighbour order on ties), the slope and the edge length;
+ no way down → itself, 0, inf."""
+ ptr = np.asarray(g.nbr_ptr, np.int64)
+ if _steep_jit is not None and g.n:
+ first, s, ok = _steep_jit(np.asarray(z), ptr, np.asarray(g.nbr_idx, np.int64), g.edge_km)
+ if ok:
+ down = s > 0
+ recv = np.where(down, g.dst[first], np.arange(g.n))
+ return recv, np.where(down, s, 0.0), np.where(down, g.edge_km[first], np.inf)
+ slope = (z[g.src] - z[g.dst]) / g.edge_km
+ if g.n == 0 or not (np.diff(ptr) > 0).all() or np.isnan(slope).any():
+ order = np.lexsort((-slope, g.src)) # general case (empty rows, NaN)
+ first = order[ptr[:-1]]
+ else: # same edge as the stable lexsort, without sorting
+ smax = np.maximum.reduceat(slope, ptr[:-1])
+ cand = np.flatnonzero(slope == smax[g.src])
+ rows = g.src[cand]
+ first = cand[np.concatenate([[True], rows[1:] != rows[:-1]])]
+ s = slope[first]
+ down = s > 0
+ ar = np.arange(g.n)
+ recv = np.where(down, g.dst[first], ar)
+ return recv, np.where(down, s, 0.0), np.where(down, g.edge_km[first], np.inf)
+
+
+def _gather(ptr, arr, sel):
+ counts = ptr[sel + 1] - ptr[sel]
+ tot = int(counts.sum())
+ if tot == 0:
+ return arr[:0]
+ starts = np.repeat(ptr[sel] - np.concatenate([[0], np.cumsum(counts)[:-1]]), counts)
+ return arr[starts + np.arange(tot)]
+
+
+def receiver_levels(recv):
+ recv = np.asarray(recv, dtype=np.int64)
+ n = len(recv)
+ ar = np.arange(n)
+ root = recv == ar
+ donors = ar[~root]
+ donors = donors[np.argsort(recv[donors], kind="stable")]
+ dptr = np.concatenate([[0], np.cumsum(np.bincount(recv[donors], minlength=n))])
+ levels, frontier, seen = [], ar[root], 0
+ while len(frontier):
+ levels.append(frontier)
+ seen += len(frontier)
+ frontier = _gather(dptr, donors, frontier)
+ if seen != n:
+ raise ValueError("receiver_levels: cycle in receivers")
+ return levels
+
+
+def accumulate(recv, levels, w):
+ """Sum w down the receiver tree. Per level only the receivers are touched (a full bincount per level costs
+ levels × cells); the sums are added in donor order from 0, as bincount does: the same floats."""
+ acc = np.asarray(w, dtype=np.float64).copy()
+ buf = np.zeros(len(acc))
+ if len(levels) > 1:
+ acc += 0.0 # as the first full-length add did: −0 becomes +0
+ for lv in reversed(levels[1:]):
+ r = recv[lv]
+ buf[r] = 0.0
+ np.add.at(buf, r, acc[lv])
+ acc[r] = acc[r] + buf[r]
+ return acc
+
+
+def _make_components_jit():
+ """Connected-component labels as scipy's connected_components numbers them — each component (every node not in
+ the mask is one by itself) by the order of its lowest node — by union-find over the edges, with no sparse matrix
+ (numba optional; WORLDGEN_NO_JIT=1 turns it off)."""
+ import os
+ if os.environ.get("WORLDGEN_NO_JIT"):
+ return None
+ try:
+ import numba
+ except ImportError:
+ return None
+
+ @numba.njit(cache=True, nogil=True)
+ def labels(n, src, dst, mask):
+ parent = np.arange(n)
+ for e in range(src.shape[0]):
+ a, b = src[e], dst[e]
+ if mask[a] and mask[b]:
+ while parent[a] != a:
+ parent[a] = parent[parent[a]]
+ a = parent[a]
+ while parent[b] != b:
+ parent[b] = parent[parent[b]]
+ b = parent[b]
+ if a != b:
+ if a < b:
+ parent[b] = a
+ else:
+ parent[a] = b
+ root_lab = np.full(n, -1, np.int32)
+ out = np.empty(n, np.int32)
+ count = 0
+ for v in range(n):
+ r = v
+ while parent[r] != r:
+ r = parent[r]
+ if root_lab[r] < 0:
+ root_lab[r] = count
+ count += 1
+ out[v] = root_lab[r] if mask[v] else -1
+ return out
+ return labels
+
+
+_components_jit = _make_components_jit()
+
+
+def components(g, mask):
+ mask = np.asarray(mask, dtype=bool)
+ if _components_jit is not None:
+ return _components_jit(g.n, g.src, g.dst, mask)
+ e = mask[g.src] & mask[g.dst]
+ m = sparse.csr_matrix((np.ones(int(e.sum())), (g.src[e], g.dst[e])), shape=(g.n, g.n))
+ _, lab = csgraph.connected_components(m, directed=False)
+ return np.where(mask, lab, -1)
+
+
+OCEAN_MIN_KM2 = 5.0e6
+
+
+def ocean_mask(g, z, min_area_km2: float = OCEAN_MIN_KM2):
+ """The connected world ocean plus any separate basin ≥ min_area_km2; smaller interior lows count as land."""
+ wet = np.asarray(z) <= 0
+ if not wet.any():
+ return wet
+ lab = components(g, wet)
+ area = np.bincount(lab[wet], weights=g.area_km2[wet])
+ keep = area >= min_area_km2
+ keep[np.argmax(area)] = True
+ return wet & keep[np.maximum(lab, 0)]
diff --git a/mapgen/grid.py b/mapgen/grid.py
new file mode 100644
index 0000000..8138b5f
--- /dev/null
+++ b/mapgen/grid.py
@@ -0,0 +1,121 @@
+"""H3 hexagonal grid as a CSR cell graph. Stage `grid`."""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from functools import cached_property
+
+import h3.api.basic_int as h3
+import numpy as np
+
+from .sphere import latlon_to_xyz, tangent_dir
+
+
+EDGE_BLOCK = 1 << 20
+
+
+def edge_blocks(n: int, block: int | None = None):
+ """Slices over n edges, EDGE_BLOCK at a time: per-edge (row-wise) formulas give the same values block by block,
+ with (block, 3) float64 temporaries instead of (edges, 3) ones (290 MB each at r5, 2 GB at r6)."""
+ block = block or EDGE_BLOCK
+ return [slice(a, min(a + block, n)) for a in range(0, n, block)]
+
+
+def rowdot_at(A, idx, t) -> np.ndarray:
+ """np.sum(A[idx] * t, axis=1) — per edge, A's row at an end dotted with the edge's vector — in edge blocks."""
+ out = np.empty(len(idx))
+ for s in edge_blocks(len(idx)):
+ out[s] = np.sum(A[idx[s]] * t[s], axis=1)
+ return out
+
+
+def cell_parents(cells, res: int) -> np.ndarray:
+ """h3.cell_to_parent for an array of cells (all of resolution ≥ res), by the index bits: the resolution field
+ set to res, the digits below it set to 7 (unused). Same ids as h3, without a Python call per cell."""
+ cells = np.asarray(cells, dtype=np.uint64)
+ if len(cells) and int(((cells >> np.uint64(52)) & np.uint64(15)).min()) < res:
+ raise ValueError(f"cell_parents: a cell is coarser than resolution {res}")
+ unused = 0
+ for r in range(res + 1, 16):
+ unused |= 7 << ((15 - r) * 3)
+ out = (cells & np.uint64(~(15 << 52) & (2**64 - 1))) | np.uint64(res << 52)
+ return out | np.uint64(unused)
+
+
+@dataclass
+class Grid:
+ res: int
+ radius_km: float
+ ids: np.ndarray
+ lat: np.ndarray
+ lon: np.ndarray
+ xyz: np.ndarray
+ area_km2: np.ndarray
+ nbr_ptr: np.ndarray
+ nbr_idx: np.ndarray
+
+ @property
+ def n(self) -> int:
+ return len(self.ids)
+
+ @cached_property
+ def counts(self):
+ return np.diff(self.nbr_ptr)
+
+ @cached_property
+ def src(self):
+ return np.repeat(np.arange(self.n), self.counts)
+
+ @property
+ def dst(self):
+ return self.nbr_idx
+
+ @cached_property
+ def edge_km(self):
+ out = np.empty(len(self.dst))
+ for s in edge_blocks(len(self.dst)):
+ d = np.sum(self.xyz[self.src[s]] * self.xyz[self.dst[s]], axis=1)
+ out[s] = self.radius_km * np.arccos(np.clip(d, -1.0, 1.0))
+ return out
+
+ @cached_property
+ def edge_tangents(self):
+ out = np.empty((len(self.dst), 3))
+ for s in edge_blocks(len(self.dst)):
+ out[s] = tangent_dir(self.xyz[self.src[s]], self.xyz[self.dst[s]])
+ return out
+
+ @cached_property
+ def spacing_km(self) -> float:
+ return float(self.edge_km.mean())
+
+ def cell_index(self, lat: float, lon: float) -> int:
+ c = np.uint64(h3.latlng_to_cell(float(lat), float(lon), self.res))
+ return int(np.searchsorted(self.ids, c))
+
+ def to_arrays(self) -> dict:
+ return {"g_ids": self.ids, "g_lat": self.lat, "g_lon": self.lon, "g_xyz": self.xyz,
+ "g_area_km2": self.area_km2, "g_nbr_ptr": self.nbr_ptr, "g_nbr_idx": self.nbr_idx}
+
+ @classmethod
+ def from_arrays(cls, d: dict, res: int, radius_km: float) -> "Grid":
+ return cls(res, radius_km, d["g_ids"], d["g_lat"], d["g_lon"], d["g_xyz"], d["g_area_km2"],
+ d["g_nbr_ptr"], d["g_nbr_idx"])
+
+
+def build_grid(res: int, radius_km: float) -> Grid:
+ cells: list[int] = []
+ for r0 in h3.get_res0_cells():
+ cells.extend(h3.cell_to_children(r0, res))
+ ids = np.array(sorted(cells), dtype=np.uint64)
+ ll = np.array([h3.cell_to_latlng(int(c)) for c in ids], dtype=np.float64)
+ area = np.array([h3.cell_area(int(c), unit="rads^2") for c in ids]) * radius_km**2
+ rings = [h3.grid_ring(int(c), 1) for c in ids]
+ counts = np.array([len(r) for r in rings], dtype=np.int64)
+ ptr = np.concatenate([[0], np.cumsum(counts)]).astype(np.int64)
+ flat = np.array([c for r in rings for c in r], dtype=np.uint64)
+ idx = np.searchsorted(ids, flat).astype(np.int64)
+ return Grid(res, radius_km, ids, ll[:, 0], ll[:, 1], latlon_to_xyz(ll[:, 0], ll[:, 1]), area, ptr, idx)
+
+
+def run(ctx) -> dict:
+ return build_grid(ctx.res, float(ctx.cfg["planet"]["radius_km"])).to_arrays()
diff --git a/mapgen/hydrology.py b/mapgen/hydrology.py
new file mode 100644
index 0000000..d7adde1
--- /dev/null
+++ b/mapgen/hydrology.py
@@ -0,0 +1,231 @@
+"""Stage `hydrology`: depression filling, lakes vs endorheic basins, discharge, rivers, Strahler order."""
+from __future__ import annotations
+
+import numpy as np
+from scipy import sparse
+from scipy.sparse import csgraph
+
+from .config import params
+from .crust import RIFT
+from .graph import accumulate, components, distance_to, priority_flood, receiver_levels, steepest_receivers
+from .noise import fbm
+from .pipeline import StageError
+
+DEFAULTS = {"fill_eps_m": 0.01, "min_depth_m": 1.0, "river_min_km3_yr": 2.0,
+ "salt_flat_max_p_mm": 300.0, "salt_flat_fraction": 0.2, "dry_lake_fraction": 0.05,
+ # lake beds: deepest point = k × area^exp m (Earth-like: ≈95 m at
+ # 1,000 km², ≈230 m at 20,000 km², ≈610 m at 500,000 km²) × 0.5–2 (noise at the lake), × rift_x in
+ # rifts, × arid_x for dry terminal lakes; never shallower than lake_min_m (refinement keeps ≥ 15 m)
+ "lake_depth_k": 12.0, "lake_depth_exp": 0.3, "lake_min_m": 25.0, "lake_max_m": 1800.0,
+ "lake_rift_x": 2.5, "lake_arid_x": 0.4, "lake_arid_p_mm": 400.0, "lake_shore": 0.25}
+
+
+def strahler(recv, levels, river):
+ n = len(recv)
+ order = np.zeros(n, np.int8)
+ mx = np.zeros(n, np.int8)
+ cnt = np.zeros(n, np.int16)
+ for lv in reversed(levels):
+ cells = lv[river[lv]]
+ if len(cells) == 0:
+ continue
+ order[cells] = np.where(cnt[cells] >= 2, mx[cells] + 1, np.maximum(mx[cells], 1))
+ nonroot = cells[recv[cells] != cells]
+ r, o = recv[nonroot], order[nonroot]
+ np.maximum.at(mx, r, o)
+ np.add.at(cnt, r, (o == mx[r]).astype(np.int16))
+ return order
+
+
+def _groups(lab):
+ idx = np.argsort(lab, kind="stable")
+ ls = lab[idx]
+ start = np.searchsorted(ls, 0)
+ idx, ls = idx[start:], ls[start:]
+ cuts = np.flatnonzero(np.diff(ls)) + 1
+ return np.split(idx, cuts) if len(idx) else []
+
+
+def _leaves(recv, lab, x, limit=100000):
+ """Does the flow from depression cell x leave its depression for good (not back in over shallow ground)?"""
+ own, y = lab[x], recv[x]
+ for _ in range(limit):
+ if lab[y] == own:
+ return False
+ if lab[y] >= 0 or recv[y] == y:
+ return True
+ y = recv[y]
+ return True
+
+
+def _leaves_all(recv, lab, levels, limit=100000):
+ """_leaves for every cell at once: walking down from recv[x], the first cell that is in a depression or a root
+ (stop) and how many steps away it is; x leaves unless that cell is in x's own depression (or the walk is longer
+ than limit steps)."""
+ n = len(recv)
+ stop, d = np.arange(n), np.zeros(n, np.int64)
+ for lv in levels[1:]: # roots first: a cell's receiver is done before it
+ free = lv[lab[lv] < 0]
+ stop[free] = stop[recv[free]]
+ d[free] = d[recv[free]] + 1
+ y = recv
+ return (d[y] >= limit) | (lab[stop[y]] != lab)
+
+
+def _route_inside(g, dep, lab, recv, targets):
+ """Within each depression, point every cell along the shortest intra-depression path to its target cell."""
+ e = dep[g.src] & dep[g.dst] & (lab[g.src] == lab[g.dst])
+ m = sparse.csr_matrix((g.edge_km[e], (g.src[e], g.dst[e])), shape=(g.n, g.n))
+ _, pred, _ = csgraph.dijkstra(m, directed=False, indices=targets, min_only=True, return_predecessors=True)
+ out = recv.copy()
+ inside = dep & (pred >= 0)
+ out[inside] = pred[inside]
+ return out
+
+
+def _sweep(recv, levels, water, outlets, cap):
+ """Accumulate flow upstream→downstream; at each spilling-lake outlet remove up to `cap` (lake evaporation)."""
+ acc = np.asarray(water, dtype=np.float64).copy()
+ loss = np.zeros(len(acc))
+ is_out = np.zeros(len(acc), bool)
+ is_out[outlets] = True
+ cap_cell = np.zeros(len(acc))
+ cap_cell[outlets] = cap
+ buf = np.zeros(len(acc)) # per level only the receivers change (graph.accumulate): same floats
+ if len(levels) > 1:
+ acc += 0.0
+ for lv in reversed(levels[1:]):
+ push = acc[lv].copy()
+ o = is_out[lv]
+ if o.any():
+ cells = lv[o]
+ lost = np.minimum(acc[cells], cap_cell[cells])
+ loss[cells] = lost
+ push[o] = acc[cells] - lost
+ r = recv[lv]
+ buf[r] = 0.0
+ np.add.at(buf, r, push)
+ acc[r] = acc[r] + buf[r]
+ return acc, loss
+
+
+def lake_levels(z, zf, lab, lake, lake_id, endo):
+ """Each lake cell's water level (NaN elsewhere): a spilling lake stands at its spill height; a terminal lake at the
+ lowest ground of its basin it does not cover (the next cell to flood). Carving the beds leaves both unchanged."""
+ level = np.full(len(z), np.nan)
+ m = int(lab.max()) + 1 if len(lab) and lab.max() >= 0 else 0
+ dry = np.full(m, np.inf) # per depression: its lowest uncovered ground
+ sel = (lab >= 0) & ~lake
+ np.minimum.at(dry, lab[sel], z[sel])
+ for c in _groups(np.where(lake, lake_id, -1)):
+ k = lab[c[0]]
+ level[c] = dry[k] if endo[c[0]] and k >= 0 and np.isfinite(dry[k]) else zf[c].min()
+ return level
+
+
+def lake_beds(g, z, level, lake, lake_id, endo, rift, p_ann, seed, P, only=None):
+ """Lake beds carved below their level (heights unchanged elsewhere): each lake's deepest point from its area
+ (P lake_*), noise keyed by where the lake lies (not its id), the depth rising from lake_shore × that at the shore
+ to all of it at the cell farthest from shore; never above the ground (min), never shallower than lake_min_m.
+ only: carve just the lakes touching these cells (eras: where events changed the ground)."""
+ z = np.asarray(z, dtype=np.float64).copy()
+ if not lake.any():
+ return z
+ shore = distance_to(g, ~lake) if (~lake).any() else np.full(g.n, g.spacing_km)
+ for c in _groups(np.where(lake, lake_id, -1)):
+ if only is not None and not only[c].any():
+ continue
+ area = g.area_km2[c].sum()
+ ctr = g.xyz[c].mean(0)
+ ctr = ctr / max(np.linalg.norm(ctr), 1e-12)
+ d = P["lake_depth_k"] * area ** P["lake_depth_exp"] * 2.0 ** float(fbm(ctr[None], seed + 4421, 3, 6.0)[0] * 1.4)
+ if rift[c].mean() > 0.3:
+ d *= P["lake_rift_x"]
+ if endo[c[0]] and p_ann[c].mean() < P["lake_arid_p_mm"]:
+ d *= P["lake_arid_x"]
+ d = float(np.clip(d, P["lake_min_m"], P["lake_max_m"]))
+ t = shore[c] / max(shore[c].max(), 1e-9)
+ prof = np.maximum(d * (P["lake_shore"] + (1 - P["lake_shore"]) * t ** 0.6), P["lake_min_m"])
+ z[c] = np.minimum(z[c], level[c] - prof)
+ return z
+
+
+def run(ctx) -> dict:
+ g = ctx.grid
+ P = params(ctx.cfg, "hydrology", DEFAULTS)
+ z, p_ann, pet = ctx.need("elevation_eroded_m", "P_ann", "PET")
+ z = z.astype(np.float64)
+ ar = np.arange(g.n)
+ ocean = np.asarray(ctx.data["ocean"]) if "ocean" in ctx.data else z <= 0
+ aet = p_ann / np.sqrt(1.0 + (p_ann / np.maximum(pet, 1e-6)) ** 2) # Pike (1964)
+ runoff = np.where(ocean, 0.0, np.maximum(p_ann - aet, 0.0))
+ water = runoff * g.area_km2 * 1e-6 # km³/yr per cell
+ zf = priority_flood(g, z, ocean, P["fill_eps_m"])
+ depth = zf - z
+ dep = ~ocean & (depth > P["min_depth_m"])
+ lab = components(g, dep)
+ recv1, _, _ = steepest_receivers(g, zf)
+ recv1 = np.where(ocean, ar, recv1)
+ levels1 = receiver_levels(recv1)
+ q1 = accumulate(recv1, levels1, water)
+ evap_net = np.maximum(pet - p_ann, 0.0) * g.area_km2 * 1e-6 # full-lake evaporation, km³/yr
+
+ groups = _groups(lab)
+ leaves = _leaves_all(recv1, lab, levels1) if len(groups) else None
+ outlets = np.zeros(len(groups), np.int64)
+ terminals = np.zeros(len(groups), np.int64)
+ cap = np.zeros(len(groups))
+ for k, c in enumerate(groups):
+ ext = c[lab[recv1[c]] != lab[c[0]]]
+ ext = ext[leaves[ext]]
+ outlets[k] = ext[np.argmax(q1[ext])] if len(ext) else c[np.argmax(q1[c])]
+ terminals[k] = c[np.argmin(z[c])]
+ cap[k] = evap_net[c].sum()
+
+ # 1) every depression drains to its spill outlet; one upstream-first sweep decides spill vs endorheic
+ recv_a = _route_inside(g, dep, lab, recv1, outlets) if len(groups) else recv1
+ acc_a, _ = _sweep(recv_a, receiver_levels(recv_a), water, outlets, cap)
+ inflow = acc_a[outlets]
+ spill = inflow >= cap
+ # 2) endorheic depressions drain to their single lowest cell instead
+ targets = np.where(spill, outlets, terminals)
+ recv = _route_inside(g, dep, lab, recv1, targets) if len(groups) else recv1
+ recv[terminals[~spill]] = terminals[~spill]
+ try:
+ levels = receiver_levels(recv)
+ except ValueError as e:
+ raise StageError(f"hydrology: {e}") from e
+ q, loss = _sweep(recv, levels, water, outlets[spill], cap[spill])
+
+ lake = np.zeros(g.n, bool)
+ endo = np.zeros(g.n, bool)
+ salt = np.zeros(g.n, bool)
+ lake_id = np.full(g.n, -1, np.int32)
+ for k, c in enumerate(groups):
+ if spill[k]:
+ lake[c] = True
+ lake_id[c] = k
+ continue
+ endo[c] = True
+ cs = c[np.argsort(z[c])]
+ nl = int(np.searchsorted(np.cumsum(evap_net[cs]), inflow[k]))
+ if inflow[k] > 0:
+ nl = max(nl, 1) # the terminal always holds some water
+ lake[cs[:nl]] = True
+ lake_id[cs[:nl]] = k
+ if nl == 0 or (nl < P["dry_lake_fraction"] * len(cs) and p_ann[c].mean() < P["salt_flat_max_p_mm"]):
+ a = np.cumsum(g.area_km2[cs])
+ ns = max(nl + 1, int(np.searchsorted(a, P["salt_flat_fraction"] * a[-1])))
+ salt[cs[nl:ns]] = True
+ river = ~ocean & ~lake & (q >= P["river_min_km3_yr"])
+ level = lake_levels(z, zf, lab, lake, lake_id, endo)
+ rift = np.asarray(ctx.data["age_class"]) == RIFT if "age_class" in ctx.data else np.zeros(g.n, bool)
+ zb = lake_beds(g, z, level, lake, lake_id, endo, rift, p_ann, ctx.seed, P, ctx.data.get("lake_carve"))
+ cut = np.asarray(ctx.data.get("lake_cut_m", 0.0), dtype=np.float64) + (z - zb) # sea masks see the uncut ground
+ z = zb
+ depth = zf - z
+ dtype = np.asarray(ctx.data["elevation_eroded_m"]).dtype
+ return {"elevation_eroded_m": z.astype(dtype), "lake_level_m": level.astype(np.float32),
+ "lake_cut_m": np.broadcast_to(cut, z.shape).astype(np.float32), "z_filled_m": zf, "recv": recv, "discharge_km3_yr": q, "runoff_mm": runoff, "aet_mm": aet,
+ "lake": lake, "lake_id": lake_id, "endorheic": endo, "salt_flat": salt, "river": river,
+ "strahler": strahler(recv, levels, river), "depression_depth_m": depth, "lake_loss_km3_yr": loss}
diff --git a/mapgen/ice.py b/mapgen/ice.py
new file mode 100644
index 0000000..2aef33f
--- /dev/null
+++ b/mapgen/ice.py
@@ -0,0 +1,37 @@
+"""Stage `ice`: ice sheets, mountain glaciers, seasonal/perennial sea ice."""
+from __future__ import annotations
+
+import numpy as np
+
+from .config import params
+from .graph import components, distance_to
+
+ICE_NONE, ICE_SHEET, ICE_GLACIER, ICE_SEA_SEASONAL, ICE_SEA_PERENNIAL = range(5)
+ICE_NAMES = ["none", "ice sheet", "glacier", "seasonal sea ice", "perennial sea ice"]
+DEFAULTS = {"melt_summer_c": 0.0, "sheet_min_km2": 250000.0, "sheet_min_p_mm": 100.0, "sea_ice_t_c": -1.8, "sheet_max_m": 3000.0,
+ "sheet_edge_m": 200.0, "sheet_growth_m_per_km": 3.0}
+
+
+def run(ctx) -> dict:
+ g = ctx.grid
+ P = params(ctx.cfg, "ice", DEFAULTS)
+ z, t_mean, t_jun, t_dec, p_ann = ctx.need("elevation_eroded_m", "T_mean", "T_jun", "T_dec", "P_ann")
+ z = z.astype(np.float64)
+ land = ~np.asarray(ctx.data["ocean"]) if "ocean" in ctx.data else z > 0
+ summer = np.where(g.lat >= 0, t_jun, t_dec)
+ winter = np.where(g.lat >= 0, t_dec, t_jun)
+ cold = land & (summer < P["melt_summer_c"]) # snow survives the summer → permanent ice
+ lab = components(g, cold)
+ area = np.bincount(lab[cold], weights=g.area_km2[cold]) if cold.any() else np.zeros(1)
+ big = cold & (area[np.maximum(lab, 0)] >= P["sheet_min_km2"])
+ sheet = big & (p_ann > P["sheet_min_p_mm"])
+ thick = np.zeros(g.n)
+ if sheet.any():
+ d_edge = distance_to(g, ~sheet)
+ thick = np.where(sheet, np.minimum(P["sheet_max_m"], P["sheet_edge_m"] + P["sheet_growth_m_per_km"] * d_edge), 0.0)
+ ice = np.zeros(g.n, np.int8)
+ ice[cold & ~sheet] = ICE_GLACIER
+ ice[sheet] = ICE_SHEET
+ ice[~land & (winter < P["sea_ice_t_c"])] = ICE_SEA_SEASONAL
+ ice[~land & (summer < P["sea_ice_t_c"])] = ICE_SEA_PERENNIAL
+ return {"ice": ice, "ice_thickness_m": thick, "z_surface_m": z + thick}
diff --git a/mapgen/minerals.py b/mapgen/minerals.py
new file mode 100644
index 0000000..ab173c9
--- /dev/null
+++ b/mapgen/minerals.py
@@ -0,0 +1,172 @@
+"""Mineral deposits: where each ore, fuel and industrial mineral can be mined, by
+geological rules, placed at an Earth-like share of the land. Everything a society needs to reach steam, rifled guns
+and aluminium airships is somewhere; the good stuff (high-grade iron ore and the alloy metals) lies mostly deep in
+mountains. Each deposit is a bit of `deposits` (uint32); `deposit_main` names the rarest one in a cell (a map layer).
+
+A deposit's cells: the land cells (not sea, not under lakes) where its rule allows it, scored by the rule × a district
+noise of its own (deposits cluster into mining districts). Half the `share` is taken province by province (≈ 1500 km
+cells, each its share of its own land, best-scored first): every region has its local bog iron, coal pits and small
+mines where its rocks allow. The rest goes to the best-scored land world-wide (the great districts)."""
+from __future__ import annotations
+
+import numpy as np
+
+from scipy.spatial import cKDTree
+
+from .noise import fbm, name_seed
+
+# name, label, share of land (fraction). Shares are rough Earth-like extents of workable districts, not grades.
+DEPOSITS = [
+ ("bog_iron", "bog / laterite iron (low grade)", 0.030),
+ ("ironstone", "ironstone (sedimentary iron)", 0.025),
+ ("iron_high", "high-grade iron (magnetite, banded)", 0.006),
+ ("coal", "coal (incl. coking)", 0.050),
+ ("copper", "copper", 0.012),
+ ("molybdenum", "molybdenum", 0.002),
+ ("tin", "tin", 0.003),
+ ("tungsten", "tungsten", 0.002),
+ ("lead_zinc", "lead & zinc", 0.010),
+ ("silver", "silver", 0.005),
+ ("gold", "gold", 0.008),
+ ("manganese", "manganese", 0.004),
+ ("chromium", "chromium", 0.0015),
+ ("nickel", "nickel", 0.003),
+ ("cobalt", "cobalt", 0.001),
+ ("vanadium", "vanadium & titanium", 0.002),
+ ("platinum", "platinum metals", 0.0005),
+ ("mercury", "mercury (cinnabar)", 0.0012),
+ ("sulfur", "sulfur", 0.004),
+ ("saltpetre", "saltpetre (nitrates)", 0.004),
+ ("bauxite", "bauxite (aluminium)", 0.010),
+ ("fluorite", "fluorite / cryolite (flux)", 0.0025),
+ ("rock_salt", "rock salt & evaporites", 0.018),
+ ("potash", "potash", 0.004),
+ ("phosphate", "phosphate", 0.005),
+ ("oil_gas", "oil & gas", 0.025),
+ ("helium", "helium (with gas)", 0.0025),
+ ("diamond", "diamonds (kimberlite)", 0.0004),
+ ("magnesite", "magnesite / dolomite (magnesium)", 0.003),
+ ("kaolin", "kaolin / fire clay", 0.010),
+ ("glass_sand", "glass sand (quartz)", 0.015),
+ ("graphite", "graphite", 0.002),
+]
+NAMES = [d[0] for d in DEPOSITS]
+BIT = {n: 1 << i for i, n in enumerate(NAMES)}
+LEGEND = ["—"] + [d[1] for d in DEPOSITS]
+# the good stuff: mostly in deep mountains
+DEEP = ("iron_high", "tungsten", "molybdenum", "chromium", "cobalt", "vanadium", "platinum", "manganese", "tin")
+
+
+PROVINCE_KM = 1500.0 # spacing of the provinces that each take their local share
+LOCAL = 0.5 # part of a deposit's share taken province by province
+LOCAL_MIN = 0.25 # ... only on ground at least this good relative to the world's marginal district
+
+
+def provinces(g, seed):
+ """Province index of each cell: the nearest of random centres ≈ PROVINCE_KM apart."""
+ n = max(1, int(round(4 * np.pi * g.radius_km ** 2 / PROVINCE_KM ** 2)))
+ c = np.random.default_rng(name_seed(seed, "deposit-provinces")).normal(size=(n, 3))
+ c /= np.linalg.norm(c, axis=1)[:, None]
+ return cKDTree(c).query(g.xyz / np.linalg.norm(g.xyz, axis=1)[:, None])[1]
+
+
+def _rank_select(score, share, land, prov):
+ """`share` × land cells where score > 0: LOCAL of it the best of each province (its share of its own land, on
+ ground ≥ LOCAL_MIN × the world's k-th best score), the rest the best world-wide."""
+ ok = np.flatnonzero((score > 0) & land)
+ k = min(len(ok), int(round(share * land.sum())))
+ out = np.zeros(len(score), bool)
+ if k == 0:
+ return out
+ cut = LOCAL_MIN * np.partition(score[ok], len(ok) - k)[len(ok) - k] # the k-th best world-wide
+ ok_l = ok[score[ok] >= cut]
+ quota = np.floor(LOCAL * share * np.bincount(prov[land], minlength=prov.max() + 1) + 0.5).astype(np.int64)
+ o = ok_l[np.lexsort((-score[ok_l], prov[ok_l]))] # by province, best first
+ p = prov[o]
+ first = np.r_[0, np.flatnonzero(p[1:] != p[:-1]) + 1]
+ rank = np.arange(len(o)) - np.repeat(first, np.diff(np.r_[first, len(o)]))
+ local = o[rank < quota[p]]
+ out[local[np.argsort(-score[local], kind="stable")[:k]]] = True
+ rest = ok[~out[ok]]
+ m = k - int(out.sum())
+ if m > 0:
+ out[rest[np.argpartition(-score[rest], m - 1)[:m]]] = True
+ return out
+
+
+def rules(f):
+ """Each deposit's suitability (≥ 0, 0 = impossible) from the geology/climate fields in f."""
+ from .crust import CRATON, LIP, POST_OROGEN, PRE_OROGEN, RIFT, VOLCANO
+ from .environment import (LF_ARC, LF_MASSIF, LF_MOUNTAINS, LI_ANDESITE, LI_BASALT, LI_GRANITE, LI_LIMESTONE,
+ LI_METAMORPHIC, LI_SANDSTONE, LF_DUNES, LF_PLAIN)
+ z, lit, age, lf, rel = f["z"], f["lit"], f["age"], f["lf"], f["relief"]
+ t, p = f["t_mean"], f["p_ann"]
+ b = lambda m: np.asarray(m, dtype=np.float64)
+ deep = np.clip((z - 800.0) / 2000.0, 0, 1) * np.clip(rel / 1500.0, 0.3, 1.0) # deep-mountain mining ground
+ deep = np.maximum(deep, 0.6 * b(np.isin(lf, [LF_MOUNTAINS, LF_MASSIF])) * np.clip(z / 2500.0, 0, 1))
+ oro = b(np.isin(age, [PRE_OROGEN, POST_OROGEN]))
+ craton = b(age == CRATON)
+ rift = b(age == RIFT)
+ arc = b((lit == LI_ANDESITE) | (lf == LF_ARC))
+ volc = b(age == VOLCANO) + arc
+ gran = b(lit == LI_GRANITE)
+ meta = b(lit == LI_METAMORPHIC)
+ lime = b(lit == LI_LIMESTONE)
+ sand = b(lit == LI_SANDSTONE)
+ basalt = b((lit == LI_BASALT) | (age == LIP))
+ sed = lime + sand
+ low = b(z < 800)
+ hot, wet = b(t > 20), b(p > 1200)
+ arid = b(p < 250) * b(t > 10)
+ suture = b(f["d_coll"] < 400) * (oro + meta)
+ return {
+ "bog_iron": b(p > 700) * low * (b(lf == LF_PLAIN) + 0.5) + hot * wet * low,
+ "ironstone": sed * b(z < 1200),
+ "iron_high": deep * (1.0 + craton + meta + oro),
+ "coal": b(f["coal"]),
+ "copper": arc * (1 + deep) + 0.4 * sand * rift + 0.3 * basalt,
+ "molybdenum": arc * deep,
+ "tin": (gran + meta) * oro * (0.3 + deep),
+ "tungsten": (gran + meta) * (oro + 0.3) * deep,
+ "lead_zinc": lime + 0.3 * sand * (1 + rift),
+ "silver": arc + 0.3 * lime + 0.3 * deep * oro,
+ "gold": (oro + craton * 0.7 + meta) * (0.3 + deep) + 0.3 * arc,
+ "manganese": (sed + craton) * (0.2 + deep),
+ "chromium": suture * deep + 0.3 * craton * basalt * deep,
+ "nickel": basalt * hot * wet + (craton + suture) * deep,
+ "cobalt": (basalt * hot * wet + craton * deep + arc * 0.3) * deep,
+ "vanadium": (basalt + craton) * deep,
+ "platinum": craton * (basalt + meta + 0.3) * deep,
+ "mercury": volc * (0.5 + 0.5 * b(f["t_mean"] > -30)),
+ "sulfur": volc + 0.5 * b(f["salt"]) + 0.3 * sed * arid,
+ "saltpetre": arid * b(lf != LF_MOUNTAINS) + 0.3 * lime * b(p > 800),
+ "bauxite": hot * wet * (gran + basalt + 0.3) * b(rel < 800) + 0.4 * lime * b(t > 14) * b(p > 600),
+ "fluorite": (gran + lime * 0.4) * (rift + oro + 0.2),
+ "rock_salt": b(f["salt"]) + 0.6 * b(f["endo"]) + 0.3 * sed * b(p < 500),
+ "potash": b(f["salt"]) + 0.3 * b(f["endo"]) + 0.2 * sed * b(p < 400),
+ "phosphate": sed * low * b(f["dist_ocean"] < 400) + 0.3 * lime,
+ "oil_gas": sed * low * (1 + b(f["d_coll"] < 1200) + rift),
+ "helium": sed * low * (craton + gran),
+ "diamond": craton * (1 + deep),
+ "magnesite": (lime + meta * 0.5) * (0.3 + deep) + 0.3 * b(f["salt"]),
+ "kaolin": (gran + sand * 0.4) * b(p > 900) * b(t > 8),
+ "glass_sand": sand * (1 + b(lf == LF_DUNES) + b(f["dist_ocean"] < 150)),
+ "graphite": meta * (0.3 + deep),
+ }
+
+
+def place(g, f, seed):
+ """(deposits uint32 bitmask, deposit_main uint8 legend index) over the grid."""
+ land = np.asarray(f["land"], bool)
+ R = rules(f)
+ prov = provinces(g, seed)
+ bits = np.zeros(g.n, np.uint32)
+ main = np.zeros(g.n, np.uint8)
+ best = np.full(g.n, np.inf)
+ for i, (name, _, share) in enumerate(DEPOSITS):
+ nz = 0.5 + 0.5 * fbm(g.xyz, name_seed(seed, "deposit:" + name), 3, 6.0) # mining districts
+ sel = _rank_select(R[name] * (0.25 + nz) ** 2, share, land, prov)
+ bits[sel] |= np.uint32(1 << i)
+ rarer = sel & (share < best)
+ main[rarer], best[rarer] = i + 1, share
+ return bits, main
diff --git a/mapgen/newworld.py b/mapgen/newworld.py
new file mode 100644
index 0000000..80109b0
--- /dev/null
+++ b/mapgen/newworld.py
@@ -0,0 +1,117 @@
+"""`mapgen.py --world DIR new-world`: a random starting world (sketch/*.png continents, config/tectonics.toml plates),
+so a build runs without drawing anything. Used for the moons; edit or redraw afterwards."""
+from __future__ import annotations
+
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+from scipy import ndimage
+
+from .noise import fbm, ridged
+from .sphere import latlon_to_xyz
+
+W, H = 2000, 1000
+
+
+def _spread(rng, n, min_deg, avoid=(), max_lat=70.0, tries=4000):
+ pts = list(avoid)
+ out = []
+ for _ in range(tries):
+ if len(out) == n:
+ break
+ lat = np.degrees(np.arcsin(rng.uniform(np.sin(np.radians(-max_lat)), np.sin(np.radians(max_lat)))))
+ lon = rng.uniform(-180, 180)
+ p = latlon_to_xyz(lat, lon)
+ if all(np.degrees(np.arccos(np.clip(p @ latlon_to_xyz(*q), -1, 1))) >= min_deg for q in pts):
+ pts.append((lat, lon))
+ out.append((float(lat), float(lon)))
+ return out
+
+
+def _centroid(mask, LAT, LON):
+ w = np.cos(np.radians(LAT)) * mask
+ p = (latlon_to_xyz(LAT, LON) * w[..., None]).sum(axis=(0, 1))
+ p /= np.linalg.norm(p)
+ return float(np.degrees(np.arcsin(p[2]))), float(np.degrees(np.arctan2(p[1], p[0])))
+
+
+def make(root: Path, seed: int, continents: int = 6, land_share: float = 0.27, toward=None, ridge: float = 0.86,
+ force: bool = False) -> None:
+ root = Path(root)
+ sk, cfg = root / "sketch", root / "config"
+ targets = [sk / "land.png", cfg / "tectonics.toml"]
+ if not force and any(p.exists() for p in targets):
+ raise SystemExit("new-world: sketch/ or config/tectonics.toml already exist (use --force to overwrite)")
+ rng = np.random.default_rng(seed)
+ w, h = W // 2, H // 2
+ LAT, LON = np.meshgrid(90.0 - (np.arange(h) + 0.5) * 180.0 / h, -180.0 + (np.arange(w) + 0.5) * 360.0 / w,
+ indexing="ij")
+ xyz = latlon_to_xyz(LAT, LON).reshape(-1, 3)
+
+ centres = _spread(rng, continents, 35.0, max_lat=55.0)
+ field = np.zeros(len(xyz))
+ for lat, lon in centres:
+ r = np.radians(rng.uniform(18.0, 34.0))
+ d = np.arccos(np.clip(xyz @ latlon_to_xyz(lat, lon), -1, 1))
+ field = np.maximum(field, np.clip(1.0 - d / r, 0.0, None) * rng.uniform(0.8, 1.2))
+ field += 0.55 * fbm(xyz, seed + 7, 6, 2.2)
+ if toward is not None:
+ field += 1.2 * (xyz @ latlon_to_xyz(*toward))
+ area = np.cos(np.radians(LAT)).ravel()
+ order = np.argsort(-field)
+ cut = field[order][np.searchsorted(np.cumsum(area[order]) / area.sum(), land_share)]
+ land = (field > cut).reshape(h, w)
+
+ mount = (ridged(xyz, seed + 11, 5, 3.0) > ridge).reshape(h, w) & ndimage.binary_erosion(land, iterations=6)
+
+ def save(name, m):
+ Image.fromarray((m * 255).astype(np.uint8), "L").resize((W, H), Image.BILINEAR).point(
+ lambda v: 255 if v > 127 else 0).save(sk / f"{name}.png")
+
+ sk.mkdir(parents=True, exist_ok=True)
+ cfg.mkdir(parents=True, exist_ok=True)
+ save("land", land)
+ save("mountains", mount)
+ for name in ("desert", "rainforest", "trench"):
+ save(name, np.zeros_like(land))
+
+ lab, n = ndimage.label(land)
+ for a, b in zip(lab[:, 0], lab[:, -1]):
+ if a and b and a != b:
+ lab[lab == b] = a
+ sizes = sorted(((int((lab == i).sum()), i) for i in np.unique(lab) if i), reverse=True)
+ plates, seeds = [], []
+ for k, (px, i) in enumerate(sizes[:continents]):
+ m = lab == i
+ if px > 0.035 * m.size: # a big landmass: two plates, a collision belt between
+ lat, lon = _centroid(m, LAT, LON)
+ ang = rng.uniform(0, np.pi)
+ side = (np.sin(ang) * (LAT - lat) + np.cos(ang) * ((LON - lon + 180) % 360 - 180) * np.cos(np.radians(lat))) > 0
+ parts = [m & side, m & ~side]
+ else:
+ parts = [m]
+ for j, part in enumerate(parts):
+ if part.sum() < 50:
+ continue
+ lat, lon = _centroid(part, LAT, LON)
+ seeds.append((lat, lon))
+ plates.append((f"continent-{k + 1}{'ab'[j] if len(parts) > 1 else ''}", lat, lon, "continental",
+ rng.uniform(0, 360), rng.uniform(2.0, 5.0)))
+ for k, (lat, lon) in enumerate(_spread(rng, 14 - len(plates) // 2, 28.0, seeds, max_lat=85.0)):
+ plates.append((f"ocean-{k + 1}", lat, lon, "oceanic", rng.uniform(0, 360), rng.uniform(3.0, 8.0)))
+
+ ocean = np.argwhere(~ndimage.binary_dilation(land, iterations=10))
+ def sea_point():
+ y, x = ocean[rng.integers(len(ocean))]
+ return float(LAT[y, x]), float(LON[y, x])
+ f = lambda v: f"[{v[0]:.1f}, {v[1]:.1f}]"
+ t = [f"# Generated by `mapgen.py new-world --seed {seed}`. Edit freely (see README.md → Tectonics).",
+ "# seed = [lat, lon] where the plate grows from; motion = [azimuth° clockwise from north, speed cm/yr].", ""]
+ for pid, lat, lon, kind, az, sp in plates:
+ t += ["[[plate]]", f'id = "{pid}"', f"seed = {f((lat, lon))}", f'kind = "{kind}"', f"motion = [{az:.0f}.0, {sp:.1f}]", ""]
+ if len(ocean):
+ t += ["[[hotspot]]", 'name = "island-chain"', f"center = {f(sea_point())}", "length_km = 600.0", ""]
+ (cfg / "tectonics.toml").write_text("\n".join(t))
+
+ print(f"new-world: {len(plates)} plates, sketch/ and config/tectonics.toml written (seed {seed})")
diff --git a/mapgen/noise.py b/mapgen/noise.py
new file mode 100644
index 0000000..e52b7f8
--- /dev/null
+++ b/mapgen/noise.py
@@ -0,0 +1,111 @@
+"""Seeded, vectorized 3D value noise and fractal sums on points (N, 3)."""
+from __future__ import annotations
+
+import zlib
+
+import numpy as np
+
+_MASK = (1 << 64) - 1
+_K1 = np.uint64(0x9E3779B185EBCA87)
+_K2 = np.uint64(0xC2B2AE3D27D4EB4F)
+_K3 = np.uint64(0x165667B19E3779F9)
+_M1 = np.uint64(0x94D049BB133111EB)
+
+
+def _hash(ix, iy, iz, seed: int):
+ s = np.uint64((seed * 0x27D4EB2F165667C5 + 0x632BE59BD9B4E019) & _MASK)
+ h = ix.astype(np.uint64) * _K1 ^ iy.astype(np.uint64) * _K2 ^ iz.astype(np.uint64) * _K3 ^ s
+ h ^= h >> np.uint64(31)
+ h *= _M1
+ h ^= h >> np.uint64(29)
+ return (h >> np.uint64(11)).astype(np.float64) / float(1 << 53) * 2.0 - 1.0
+
+
+def value_noise(p, seed: int):
+ if _noise_jit is not None:
+ p = np.asarray(p)
+ if p.dtype == np.float64 and p.ndim == 2 and p.shape[1] == 3:
+ s = np.uint64((seed * 0x27D4EB2F165667C5 + 0x632BE59BD9B4E019) & _MASK)
+ return _noise_jit(np.ascontiguousarray(p), s)
+ return _value_noise(p, seed)
+
+
+def _value_noise(p, seed: int):
+ f = np.floor(p)
+ i = f.astype(np.int64)
+ t = p - f
+ t = t * t * (3.0 - 2.0 * t)
+ out = np.zeros(len(p))
+ for dx in (0, 1):
+ wx = t[:, 0] if dx else 1.0 - t[:, 0]
+ for dy in (0, 1):
+ wy = t[:, 1] if dy else 1.0 - t[:, 1]
+ for dz in (0, 1):
+ wz = t[:, 2] if dz else 1.0 - t[:, 2]
+ out += wx * wy * wz * _hash(i[:, 0] + dx, i[:, 1] + dy, i[:, 2] + dz, seed)
+ return out
+
+
+def _make_noise_jit():
+ """value_noise compiled (numba optional): per point the same integer hash and the same float steps in the same
+ order, so the same values; one call costs microseconds instead of a dozen numpy passes (river meanders call it
+ on a few points at a time). WORLDGEN_NO_JIT=1 turns it off."""
+ import os
+ if os.environ.get("WORLDGEN_NO_JIT"):
+ return None
+ try:
+ import numba
+ except ImportError:
+ return None
+ K1, K2, K3, M1 = _K1, _K2, _K3, _M1
+
+ @numba.njit(cache=True)
+ def noise(p, s):
+ n = p.shape[0]
+ out = np.empty(n)
+ for k in range(n):
+ fx, fy, fz = np.floor(p[k, 0]), np.floor(p[k, 1]), np.floor(p[k, 2])
+ ix, iy, iz = np.int64(fx), np.int64(fy), np.int64(fz)
+ tx, ty, tz = p[k, 0] - fx, p[k, 1] - fy, p[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)
+ acc = 0.0
+ for dx in range(2):
+ wx = tx if dx else 1.0 - tx
+ for dy in range(2):
+ wy = ty if dy else 1.0 - ty
+ for dz in range(2):
+ wz = tz if dz else 1.0 - tz
+ h = (np.uint64(ix + dx) * K1) ^ (np.uint64(iy + dy) * K2) ^ (np.uint64(iz + dz) * K3) ^ s
+ h ^= h >> np.uint64(31)
+ h *= M1
+ h ^= h >> np.uint64(29)
+ v = np.float64(h >> np.uint64(11)) / 9007199254740992.0 * 2.0 - 1.0
+ acc += wx * wy * wz * v
+ out[k] = acc
+ return out
+ return noise
+
+
+_noise_jit = _make_noise_jit()
+
+
+def fbm(xyz, seed: int, octaves: int = 5, freq: float = 2.0, lacunarity: float = 2.0, gain: float = 0.5):
+ total = np.zeros(len(xyz))
+ amp, norm, f = 1.0, 0.0, freq
+ for o in range(octaves):
+ total += amp * value_noise(xyz * f, seed + 1013 * o)
+ norm += amp
+ amp *= gain
+ f *= lacunarity
+ return total / norm
+
+
+def ridged(xyz, seed: int, octaves: int = 5, freq: float = 2.0):
+ return 1.0 - np.abs(fbm(xyz, seed, octaves, freq))
+
+
+def name_seed(seed: int, name: str) -> int:
+ """A per-feature noise seed: the build seed plus a hash of the feature's name (stable across runs)."""
+ return seed + zlib.crc32(name.encode()) % 100000
diff --git a/mapgen/ocean.py b/mapgen/ocean.py
new file mode 100644
index 0000000..67dfaf7
--- /dev/null
+++ b/mapgen/ocean.py
@@ -0,0 +1,166 @@
+"""Ocean circulation for stage `climate`: wind-driven surface currents, sea-surface temperature, Ekman upwelling
+and a productivity index.
+
+Currents: the Stommel model on the sphere, r∇²ψ + βψ_x = k·curl(τ/ρH), solved for the stream function ψ over
+every cell; land is the same fluid with `land_friction`× the friction (Brinkman penalisation), so coasts block the
+flow and islands need no special treatment. u = k × ∇ψ. Western boundary currents come out ≈ r/β wide.
+"""
+from __future__ import annotations
+
+import numpy as np
+from scipy import sparse
+from scipy.sparse import linalg as splinalg
+
+from .graph import bicgstab_jacobi, distance_to, gradient, pmap, smooth_km
+from .grid import rowdot_at
+from .sphere import east_north
+
+DEFAULTS = {
+ "enabled": True, "friction_days": 5.0, "layer_m": 150.0, "stress_k": 1.0, "land_friction": 1000.0,
+ "rho_air": 1.2, "drag": 1.3e-3, "direct_max_cells": 500000,
+ "relax_days": 300.0, "kappa_m2s": 1000.0,
+ "ekman_min_lat": 3.0, "upwell_coast_km": 100.0,
+ "prod_upwell": 0.6, "prod_shelf": 0.4, "prod_mix": 0.3, "upwell_ref_m_yr": 100.0,
+ "prod_front": 0.4, "front_min": 0.5, "front_ref": 1.0,
+}
+RHO_W = 1025.0
+YEAR_S = 3.15576e7
+
+
+def omega(day_hours):
+ return 2.0 * np.pi / (float(day_hours) * 3600.0)
+
+
+def divergence(g, V):
+ """Graph divergence of a tangent field (V per km): (2/k_i) Σ_j V_j·t_ij / d_ij (exact for linear V on a hex
+ lattice; the V_i terms cancel)."""
+ w = rowdot_at(V, g.dst, g.edge_tangents) / g.edge_km
+ return np.bincount(g.src, weights=w, minlength=g.n) * (2.0 / g.counts)
+
+
+def _edge_operator(g, w):
+ """Sparse operator f ↦ Σ_j w_ij (f_j − f_i)."""
+ A = sparse.csr_matrix((w, (g.src, g.dst)), shape=(g.n, g.n))
+ return A - sparse.diags(np.asarray(A.sum(axis=1)).ravel())
+
+
+def wind_stress(wind, P):
+ """Bulk formula τ = k·ρ_air·C_d·|w|·w (N/m²)."""
+ wind = np.asarray(wind, dtype=np.float64)
+ return P["stress_k"] * P["rho_air"] * P["drag"] * np.linalg.norm(wind, axis=1, keepdims=True) * wind
+
+
+def streamfunction(g, ocean, wind, P, day_hours):
+ """ψ (m²/s) of the wind-driven surface flow; one direct sparse solve over every cell."""
+ Om = omega(day_hours)
+ R = g.radius_km * 1e3
+ beta = 2.0 * Om * np.cos(np.radians(g.lat)) / R # 1/(m·s)
+ beta30 = 2.0 * Om * np.cos(np.radians(30.0)) / R
+ r = max(1.0 / (P["friction_days"] * 86400.0), beta30 * g.spacing_km * 1e3) # boundary layer ≥ one cell
+ F = np.where(ocean[:, None], wind_stress(wind, P), 0.0) / (RHO_W * P["layer_m"])
+ curl = divergence(g, np.cross(F, g.xyz)) * 1e-3 # k·curl F = ∇·(F×k), 1/s²
+ fr = np.where(ocean, 1.0, P["land_friction"])
+ fe = 2.0 / (1.0 / fr[g.src] + 1.0 / fr[g.dst]) # harmonic mean: coasts count as land
+ L = _edge_operator(g, 4.0 / (g.counts[g.src] * g.edge_km ** 2) * fe) # ∇·(fr∇), per km²
+ e, _ = east_north(g.xyz)
+ Dx = _edge_operator(g, (2.0 / g.counts[g.src]) * rowdot_at(e, g.src, g.edge_tangents) / g.edge_km)
+ A = (L + sparse.diags(beta / r * 1e3) @ Dx).tolil() # β/r·1e3: per km
+ b = 1e6 * curl / r
+ # gauge: ψ is defined up to a constant; pin it deep inland, where land friction soaks up the solve's
+ # compatibility residual (pinned at sea it would leave a point vortex there)
+ k = int(np.argmax(distance_to(g, ocean))) if (~ocean).any() else 0
+ A[k, :] = 0.0
+ A[k, k] = 1.0
+ b[k] = 0.0
+ return splinalg.spsolve(A.tocsc(), b)
+
+
+def velocity(g, psi):
+ """u = k × ∇ψ (m/s), tangent 3-vectors."""
+ return np.cross(g.xyz, gradient(g, psi) * 1e-3)
+
+
+def _coarse_currents(g, ocean, wind, P, day_hours):
+ """Grids too big for a direct solve: solve on the parent H3 resolution, carry u down, smooth."""
+ from .grid import build_grid, cell_parents
+ cg = build_grid(g.res - 1, g.radius_km)
+ parent = np.searchsorted(cg.ids, cell_parents(g.ids, g.res - 1))
+ cnt = np.maximum(np.bincount(parent, minlength=cg.n), 1)
+ c_ocean = np.bincount(parent, weights=ocean.astype(float), minlength=cg.n) / cnt > 0.5
+ c_wind = np.stack([np.bincount(parent, weights=wind[:, k], minlength=cg.n) / cnt for k in range(3)], axis=1)
+ cu = currents(cg, c_ocean, c_wind, P, day_hours)
+ u = np.stack(pmap(lambda k: smooth_km(g, cu[parent, k], cg.spacing_km), range(3)), axis=1)
+ return u - np.sum(u * g.xyz, axis=1, keepdims=True) * g.xyz # back into the tangent plane
+
+
+def currents(g, ocean, wind, P, day_hours):
+ """Surface current (n,3) m/s; 0 on land."""
+ ocean = np.asarray(ocean, bool)
+ wind = np.asarray(wind, dtype=np.float64)
+ if not ocean.any():
+ return np.zeros((g.n, 3))
+ if g.n > P["direct_max_cells"]:
+ u = _coarse_currents(g, ocean, wind, P, day_hours)
+ else:
+ u = velocity(g, streamfunction(g, ocean, wind, P, day_hours))
+ return np.where(ocean[:, None], u, 0.0)
+
+
+def _masked_edges(g, mask, w):
+ return _edge_operator(g, np.where(mask[g.src] & mask[g.dst], w, 0.0))
+
+
+def sst(g, ocean, u, T_eq, P):
+ """Annual-mean sea-surface temperature (°C): steady u·∇T − κ∇²T = λ(T_eq − T) over the ocean (upwind
+ advection, no flux into land); land keeps T_eq."""
+ ocean = np.asarray(ocean, bool)
+ T_eq = np.asarray(T_eq, dtype=np.float64)
+ lam = 1.0 / (P["relax_days"] * 86400.0)
+ up = (4.0 / g.counts[g.src]) * np.maximum(-rowdot_at(u, g.src, g.edge_tangents), 0.0) / (g.edge_km * 1e3)
+ adv = -_masked_edges(g, ocean, up) # Σ_upstream c_ij (T_i − T_j), 1/s
+ dif = _masked_edges(g, ocean, 4.0 / (g.counts[g.src] * g.edge_km ** 2)) * (P["kappa_m2s"] * 1e-6)
+ A = (adv - dif) / lam + sparse.identity(g.n)
+ A = (sparse.diags(ocean.astype(float)) @ A + sparse.diags((~ocean).astype(float))).tocsr()
+ T, info = bicgstab_jacobi(A, T_eq, T_eq.copy(), 1.0 / A.diagonal(), 1e-9, 5000)
+ if info != 0:
+ raise ValueError(f"sst: solver did not converge (info={info})")
+ return T
+
+
+def upwelling(g, ocean, wind, P, day_hours):
+ """Ekman pumping (m/yr, + up): w = ∇·M, M = τ×k/(ρf), |f| floored at `ekman_min_lat`. Transport pointing off a
+ coast leaves the coast cell (land carries none), so coastal upwelling needs no separate rule. Smoothed over
+ `upwell_coast_km`; 0 on land."""
+ ocean = np.asarray(ocean, bool)
+ Om = omega(day_hours)
+ fmin = 2.0 * Om * np.sin(np.radians(P["ekman_min_lat"]))
+ f = np.where(g.lat >= 0, 1.0, -1.0) * np.maximum(np.abs(2.0 * Om * np.sin(np.radians(g.lat))), fmin)
+ tau = np.where(ocean[:, None], wind_stress(wind, P), 0.0)
+ M = np.cross(tau, g.xyz) / (RHO_W * f[:, None]) # m²/s
+ w = np.where(ocean, divergence(g, M) * 1e-3 * YEAR_S, 0.0)
+ return np.where(ocean, smooth_km(g, w, P["upwell_coast_km"]), 0.0)
+
+
+def sst_front(g, ocean, sst_c):
+ """|∇SST| over the sea (°C per 100 km); edges to land count as flat, so coasts are no front."""
+ sst_c = np.asarray(sst_c, dtype=np.float64)
+ both = ocean[g.src] & ocean[g.dst]
+ w = np.where(both, sst_c[g.dst] - sst_c[g.src], 0.0) / g.edge_km
+ grad = np.stack([np.bincount(g.src, weights=w * g.edge_tangents[:, c], minlength=g.n) for c in range(3)], axis=1)
+ return np.where(ocean, np.linalg.norm(grad, axis=1) * (2.0 / g.counts) * 100.0, 0.0)
+
+
+def productivity(g, ocean, w, z, t_range, sst_c, P):
+ """0–1 sea productivity: upwelling (saturating), shallow shelf, winter mixing and SST fronts (where warm and cold
+ currents meet, e.g. a Brazil–Malvinas confluence; gradients under `front_min` °C/100 km add nothing), dimmed
+ toward the poles."""
+ ocean = np.asarray(ocean, bool)
+ depth = np.maximum(-np.asarray(z, dtype=np.float64), 0.0)
+ x = np.maximum(w, 0.0) / P["upwell_ref_m_yr"]
+ shelf = np.clip((1000.0 - depth) / 800.0, 0.0, 1.0)
+ mix = np.clip(np.asarray(t_range) / 20.0, 0.0, 1.0) * np.clip((20.0 - np.asarray(sst_c)) / 20.0, 0.0, 1.0)
+ light = 0.3 + 0.7 * np.cos(np.radians(g.lat))
+ xf = np.maximum(sst_front(g, ocean, sst_c) - P["front_min"], 0.0) / P["front_ref"]
+ p = (P["prod_upwell"] * x / (1.0 + x) + P["prod_shelf"] * shelf + P["prod_mix"] * mix
+ + P["prod_front"] * xf / (1.0 + xf)) * light
+ return np.where(ocean, np.clip(p, 0.0, 1.0), 0.0)
diff --git a/mapgen/pipeline.py b/mapgen/pipeline.py
new file mode 100644
index 0000000..fca2fa1
--- /dev/null
+++ b/mapgen/pipeline.py
@@ -0,0 +1,180 @@
+"""Stage runner with an input-hash cache."""
+from __future__ import annotations
+
+import hashlib
+import importlib
+import os
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+
+import numpy as np
+
+from . import config as C
+from .grid import Grid
+
+STAGES = ["grid", "sketch", "plates", "crust", "elevation", "erosion", "climate",
+ "hydrology", "seabed", "environment", "ice", "fields", "render"]
+
+
+class StageError(RuntimeError):
+ pass
+
+
+@dataclass
+class Ctx:
+ root: Path
+ cfg: dict
+ tect: dict
+ res: int
+ data: dict = field(default_factory=dict)
+ grid: Grid | None = None
+ out_dir: Path | None = None # render: out/r<res> unless set (an era writes out/r<res>/eras/<era>)
+ preview_dir: Path | None = None
+ low_memory: bool = False # trade speed for a lower memory peak; never changes the results
+
+ @property
+ def seed(self) -> int:
+ return int(self.cfg["build"]["seed"])
+
+ def need(self, *keys):
+ missing = [k for k in keys if k not in self.data]
+ if missing:
+ raise StageError(f"missing inputs {missing}: run the stage that produces them first")
+ return [self.data[k] for k in keys]
+
+
+def inputs_key(root: Path, res: int) -> str:
+ h = hashlib.sha256(str(res).encode())
+ files = []
+ for sub, pat in (("config", "*.toml"), ("masks", "*.png"), ("sketch", "*.png")):
+ files += sorted(p for p in (root / sub).glob(pat) if p.name != C.ERAS_FILE) # eras: their own key
+ files += sorted(Path(__file__).resolve().parent.glob("*.py"))
+ for p in files:
+ h.update(p.name.encode())
+ h.update(p.read_bytes())
+ return h.hexdigest()[:16]
+
+
+ALIGN = 64
+
+
+def save_npz_aligned(path, /, **arrays) -> None:
+ """np.savez(path, **arrays), but each array's data starts at a multiple of ALIGN bytes in the file (the local zip
+ header gets a padding extra field, as zipalign does): an ordinary .npz for np.load, whose arrays npz_maps can
+ map in place. Alignment matters for more than speed: numpy sums 8-byte-misaligned data in buffered chunks,
+ i.e. in another order — mapped misaligned inputs would change the last bits of results."""
+ import io
+ import struct
+ import zipfile
+ from numpy.lib import format as F
+ with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as zf:
+ for name, v in arrays.items():
+ v = np.asarray(v)
+ if v.dtype.hasobject:
+ raise ValueError(f"save_npz_aligned: {name} has object dtype")
+ head = io.BytesIO()
+ d = F.header_data_from_array_1_0(v)
+ try:
+ F.write_array_header_1_0(head, d)
+ except ValueError:
+ head = io.BytesIO()
+ F.write_array_header_2_0(head, d)
+ info = zipfile.ZipInfo(f"{name}.npy", date_time=(1980, 1, 1, 0, 0, 0))
+ info.compress_type = zipfile.ZIP_STORED
+ start = zf.fp.tell() + 30 + len(info.filename.encode()) + 20 + len(head.getvalue()) # 20: zip64 field
+ pad = -start % ALIGN
+ if 0 < pad < 4: # an extra field is at least its 4-byte header
+ pad += ALIGN
+ if pad:
+ info.extra = struct.pack("<HH", 0xA1A1, pad - 4) + bytes(pad - 4)
+ with zf.open(info, "w", force_zip64=True) as m:
+ m.write(head.getvalue())
+ _write_data(m, v)
+
+
+def _write_data(m, v) -> None:
+ """The array's bytes (C order, or Fortran order for an F-contiguous array, as np.save) in 16 MB pieces."""
+ flat = v.T.reshape(-1) if (v.flags.f_contiguous and not v.flags.c_contiguous) else np.ascontiguousarray(v).reshape(-1)
+ step = max(1, (16 << 20) // max(v.itemsize, 1))
+ for i in range(0, flat.size, step):
+ m.write(flat[i:i + step].tobytes())
+
+
+def npz_maps(f: Path) -> dict:
+ """The arrays of an uncompressed .npz (np.savez) mapped from the file, copy-on-write: plain writable arrays with
+ the same values, whose pages the OS reads on use and can drop again (writes stay private, the file never
+ changes). Members that can't be mapped (compressed, object dtype) are read into memory as np.load would."""
+ import mmap
+ import struct
+ import zipfile
+ out = {}
+ with open(f, "rb") as fh, zipfile.ZipFile(fh) as zf:
+ mm = mmap.mmap(fh.fileno(), 0, access=mmap.ACCESS_COPY)
+ for info in zf.infolist():
+ name = info.filename[:-4] if info.filename.endswith(".npy") else info.filename
+ ok = info.compress_type == zipfile.ZIP_STORED
+ if ok:
+ fh.seek(info.header_offset)
+ local = fh.read(30)
+ n_name, n_extra = struct.unpack("<HH", local[26:30])
+ fh.seek(info.header_offset + 30 + n_name + n_extra)
+ version = np.lib.format.read_magic(fh)
+ read_header = {(1, 0): np.lib.format.read_array_header_1_0,
+ (2, 0): np.lib.format.read_array_header_2_0}.get(version)
+ ok = read_header is not None
+ if ok:
+ shape, fortran, dtype = read_header(fh, max_header_size=1 << 20)
+ ok = not dtype.hasobject
+ ok = fh.tell() % ALIGN == 0 # misaligned: read (see save_npz_aligned)
+ if ok:
+ count = int(np.prod(shape, dtype=np.int64))
+ a = np.frombuffer(mm, dtype=dtype, count=count, offset=fh.tell()) if count else np.empty(0, dtype)
+ out[name] = a.reshape(shape, order="F" if fortran else "C")
+ else:
+ with zf.open(info) as m:
+ out[name] = np.lib.format.read_array(m, allow_pickle=False)
+ return out
+
+
+def _after(ctx: Ctx, name: str) -> None:
+ if name == "grid":
+ ctx.grid = Grid.from_arrays(ctx.data, ctx.res, float(ctx.cfg["planet"]["radius_km"]))
+
+
+def build(root: Path, res: int, start: str | None = None, stop: str | None = None, log=print,
+ low_memory: bool = False) -> Ctx:
+ cfg, tect = C.load(root)
+ ctx = Ctx(root, cfg, tect, res, low_memory=low_memory)
+ cache = root / "out" / "cache" / f"r{res}"
+ cache.mkdir(parents=True, exist_ok=True)
+ key = inputs_key(root, res)
+ stages = STAGES[: STAGES.index(stop) + 1] if stop else STAGES
+ forced = False
+ for name in stages:
+ forced = forced or name == start
+ f = cache / f"{name}.npz"
+ t0 = time.time()
+ if not forced and f.exists():
+ with np.load(f, allow_pickle=False) as z:
+ hit = str(z["_key"]) == key
+ if hit and not ctx.low_memory:
+ ctx.data.update({k: z[k] for k in z.files if k != "_key"})
+ if hit:
+ if ctx.low_memory:
+ ctx.data.update({k: v for k, v in npz_maps(f).items() if k != "_key"})
+ _after(ctx, name)
+ log(f"{name}: cached")
+ continue
+ forced = True
+ out = importlib.import_module(f"mapgen.{name}").run(ctx)
+ tmp = f.with_name(f"{f.stem}.tmp-{os.getpid()}.npz")
+ save_npz_aligned(tmp, _key=np.array(key), **out)
+ os.replace(tmp, f) # never truncated in place: maps of the old file stay valid
+ if ctx.low_memory: # fields kept on disk from here on (the cache file just written)
+ maps = npz_maps(f)
+ out = {k: maps.get(k, v) if isinstance(v, np.ndarray) else v for k, v in out.items()}
+ ctx.data.update(out)
+ _after(ctx, name)
+ log(f"{name}: {time.time() - t0:.1f}s")
+ return ctx
diff --git a/mapgen/plateaus.py b/mapgen/plateaus.py
new file mode 100644
index 0000000..9647d04
--- /dev/null
+++ b/mapgen/plateaus.py
@@ -0,0 +1,179 @@
+"""Sunken plateaus: Kerguelen-type continental crust that never rose. Outline,
+surface and volcanic field are functions of position and the plateau's config (deterministic per name), so the world
+build, the seabed pass and the viewer agree at any resolution."""
+from __future__ import annotations
+
+import numpy as np
+
+from .graph import distance_to
+from .noise import fbm, name_seed
+from .sphere import east_north, gc_dist_km, great_circle_point, latlon_to_xyz, tangent_dir, xyz_to_latlon
+from .zones import smootherstep
+
+EDGE_WARP = 0.15 # outline radius ± 15 % (fractal noise): bays and lobes, like a small continent
+MARGIN_KM = 150.0 # the surface blends down to the surrounding sea floor over this distance inside the outline
+RELIEF_M = 400.0 # internal relief (ridges, basins)
+HIDDEN_MAX_M = -550.0 # hidden plateaus: every cell at least this deep (checked at −500 m after erosion's re-solve)
+SITE_RULES = (("land", 400.0), ("a plate boundary", 150.0), ("the trench", 200.0)) # km from the outline
+
+
+def semi_axes(p: dict):
+ """(long, short) semi-axes (km) of the plateau's ellipse: π·a·b = area_km2, a / b = elongation."""
+ e = float(p.get("elongation", 1.0))
+ b = float(np.sqrt(p["area_km2"] / (np.pi * e)))
+ return e * b, b
+
+
+def _near(xyz, p, radius_km, pad_km=0.0):
+ a, _ = semi_axes(p)
+ lim = min(np.pi, (a * (1.0 + EDGE_WARP) / (1.0 - EDGE_WARP) + pad_km) / radius_km)
+ return xyz @ latlon_to_xyz(*p["center"]) > np.cos(lim)
+
+
+def rho(xyz, p: dict, seed: int, radius_km: float):
+ """Normalised radius of points: 0 at the centre, < 1 inside the noise-warped elliptical outline."""
+ xyz = np.asarray(xyz, dtype=np.float64)
+ c = latlon_to_xyz(*p["center"])
+ e, n = east_north(c[None])
+ ang = np.arccos(np.clip(xyz @ c, -1.0, 1.0)) * radius_km # km from the centre along the surface
+ t = tangent_dir(np.broadcast_to(c, xyz.shape), xyz)
+ x, y = ang * (t @ e[0]), ang * (t @ n[0]) # east, north (azimuthal equidistant)
+ az = np.radians(p.get("azimuth_deg", 0.0))
+ along, across = x * np.sin(az) + y * np.cos(az), x * np.cos(az) - y * np.sin(az)
+ a, b = semi_axes(p)
+ r = np.sqrt((along / a) ** 2 + (across / b) ** 2)
+ w = np.clip(2.0 * fbm(xyz, name_seed(seed, p["name"]), 4, 25.0), -1.0, 1.0)
+ return r / (1.0 + EDGE_WARP * w)
+
+
+def cell_ids(xyz, plateaus: list, seed: int, radius_km: float):
+ """Plateau index per point (−1 outside every plateau)."""
+ xyz = np.asarray(xyz, dtype=np.float64)
+ ids = np.full(len(xyz), -1, np.int16)
+ for k, p in enumerate(plateaus):
+ idx = np.flatnonzero(_near(xyz, p, radius_km))
+ if len(idx):
+ ids[idx[rho(xyz[idx], p, seed, radius_km) < 1.0]] = k
+ return ids
+
+
+def _place(rng, p, seed, radius_km, origin, max_km, inside=0.85, tries=30):
+ """A random point within max_km of origin inside the outline (rho < inside); None if none is found."""
+ e, n = east_north(origin[None])
+ for _ in range(tries):
+ az, dist = rng.uniform(0.0, 2.0 * np.pi), rng.uniform(0.0, max_km)
+ q = great_circle_point(origin, np.sin(az) * e[0] + np.cos(az) * n[0], dist, radius_km)
+ if rho(q[None], p, seed, radius_km)[0] < inside:
+ return q
+ return None
+
+
+def _ll(q):
+ lat, lon = xyz_to_latlon(np.asarray(q, dtype=np.float64))
+ return round(float(lat), 4), round(float(lon), 4)
+
+
+def features(p: dict, seed: int, radius_km: float) -> dict:
+ """The plateau's volcanic field at world scale (deterministic per name): its own hotspot point, 6–20 cones and
+ 1–3 calderas (none when vent = 0); island plateaus lift 3–6 of the cones to +0.6…+1.5 km."""
+ rng = np.random.default_rng(name_seed(seed, p["name"]) + 7)
+ c = latlon_to_xyz(*p["center"])
+ _, b = semi_axes(p)
+ vent = float(p.get("vent", 1.0))
+ hot = _place(rng, p, seed, radius_km, c, 0.3 * b)
+ hot = c if hot is None else hot
+ cones, calderas = [], []
+ if vent > 0:
+ for _ in range(int(rng.integers(6, 21))):
+ q = _place(rng, p, seed, radius_km, hot, 0.6 * b)
+ r_km, h = float(rng.uniform(15.0, 40.0)), float(rng.uniform(500.0, 2000.0)) * vent
+ if q is not None:
+ lat, lon = _ll(q)
+ cones.append({"lat": lat, "lon": lon, "radius_km": r_km, "height_m": h, "island": False})
+ for _ in range(int(rng.integers(1, 4))):
+ q = _place(rng, p, seed, radius_km, hot, 0.5 * b)
+ r_km = float(rng.uniform(20.0, 50.0))
+ if q is not None:
+ lat, lon = _ll(q)
+ calderas.append({"lat": lat, "lon": lon, "radius_km": r_km, "rim_m": 300.0 * vent,
+ "floor_m": -400.0 * vent})
+ if p.get("islands", False) and cones:
+ k = min(len(cones), int(rng.integers(3, 7)))
+ for i in rng.choice(len(cones), size=k, replace=False):
+ cones[int(i)].update(island=True, peak_m=float(rng.uniform(600.0, 1500.0)),
+ radius_km=float(rng.uniform(40.0, 70.0)))
+ return {"name": p["name"], "center": [float(v) for v in p["center"]], "hotspot": list(_ll(hot)),
+ "cones": cones, "calderas": calderas}
+
+
+def surface(xyz, p: dict, feat: dict, seed: int, radius_km: float, z_floor):
+ """Heights of points inside the outline: the plateau top (depth across top_m by low-frequency noise, ± RELIEF_M)
+ plus its cones and calderas, blended down to z_floor over MARGIN_KM inside the edge."""
+ xyz = np.asarray(xyz, dtype=np.float64)
+ s = name_seed(seed, p["name"])
+ t = np.clip(0.5 + 1.5 * fbm(xyz, s + 1, 3, 8.0), 0.0, 1.0)
+ top0, top1 = p["top_m"]
+ z = -(top0 + (top1 - top0) * t) + RELIEF_M * np.clip(2.5 * fbm(xyz, s + 2, 5, 40.0), -1.0, 1.0)
+ for c in feat["cones"]:
+ if not c["island"]:
+ d = gc_dist_km(xyz, latlon_to_xyz(c["lat"], c["lon"]), radius_km)
+ z = z + c["height_m"] * np.clip(1.0 - d / c["radius_km"], 0.0, 1.0) ** 1.5
+ for c in feat["calderas"]:
+ d = gc_dist_km(xyz, latlon_to_xyz(c["lat"], c["lon"]), radius_km)
+ r = c["radius_km"]
+ z = z + c["rim_m"] * np.exp(-((d - r) / (0.25 * r)) ** 2) + c["floor_m"] * (1.0 - smootherstep(d / r))
+ _, b = semi_axes(p)
+ w = smootherstep((1.0 - rho(xyz, p, seed, radius_km)) * b / MARGIN_KM)
+ return np.asarray(z_floor, dtype=np.float64) + (z - z_floor) * w
+
+
+def islands(xyz, feat: dict, radius_km: float, z):
+ """Island cones lift the ground to their peak_m (small volcanic islands); only raises."""
+ z = np.asarray(z, dtype=np.float64)
+ for c in feat["cones"]:
+ if c["island"]:
+ d = gc_dist_km(np.asarray(xyz, dtype=np.float64), latlon_to_xyz(c["lat"], c["lon"]), radius_km)
+ f = np.clip(d / c["radius_km"], 0.0, 1.0)
+ z = np.where(d < c["radius_km"], np.maximum(z, c["peak_m"] - (c["peak_m"] - z) * f ** 1.2), z)
+ return z
+
+
+def apply(g, z, plateaus: list, plateau_id, seed: int):
+ """Grid heights with the plateaus set (after the world's sea-level solve): surfaces and volcanic fields; hidden
+ plateaus clamped to HIDDEN_MAX_M; islands, each island's nearest cell raised to its peak (so every resolution
+ keeps it)."""
+ z = np.asarray(z, dtype=np.float64).copy()
+ R = g.radius_km
+ for k, p in enumerate(plateaus):
+ idx = np.flatnonzero(np.asarray(plateau_id) == k)
+ if len(idx) == 0:
+ continue
+ feat = features(p, seed, R)
+ zk = surface(g.xyz[idx], p, feat, seed, R, z[idx])
+ if p.get("islands", False):
+ z[idx] = islands(g.xyz[idx], feat, R, zk)
+ for c in feat["cones"]:
+ if c["island"]:
+ i = g.cell_index(c["lat"], c["lon"])
+ z[i] = max(z[i], c["peak_m"])
+ else:
+ z[idx] = np.minimum(zk, HIDDEN_MAX_M)
+ return z
+
+
+def site_report(g, data: dict, plateaus: list) -> list:
+ """Plateaus that no longer fit their site on this world, as warnings: outline ≥ 400 km from land,
+ ≥ 150 km from plate boundaries, ≥ 200 km from the sketch's trench. Reported, never moved."""
+ ids = np.asarray(data["plateau_id"])
+ masks = {"land": ~np.asarray(data["ocean"]) & (ids < 0), "a plate boundary": np.asarray(data["bnd_type"]) > 0,
+ "the trench": np.asarray(data.get("sk_trench", np.zeros(g.n))) > 0.5}
+ out = []
+ for what, lim in SITE_RULES:
+ if not masks[what].any():
+ continue
+ d = distance_to(g, masks[what])
+ for k, p in enumerate(plateaus):
+ m = ids == k
+ if m.any() and float(d[m].min()) < lim:
+ out.append(f"plateau {p['name']}: {float(d[m].min()):.0f} km from {what} (rule ≥ {lim:.0f} km)")
+ return out
diff --git a/mapgen/plates.py b/mapgen/plates.py
new file mode 100644
index 0000000..af798bf
--- /dev/null
+++ b/mapgen/plates.py
@@ -0,0 +1,86 @@
+"""Stage `plates`: grow plates from seeds, assign Euler motions, classify boundaries."""
+from __future__ import annotations
+
+import numpy as np
+from scipy.spatial import cKDTree
+
+from .config import params
+from .graph import nearest_source
+from .grid import edge_blocks
+from .noise import fbm
+from .pipeline import StageError
+from .sphere import motion_to_omega, velocity
+
+NONE, CONV, DIV, TRANS = 0, 1, 2, 3
+DEFAULTS = {"land_cost": 0.25, "noise": 0.35, "noise_freq": 3.0, "warp_km": 1000.0, "warp_freq": 1.5,
+ "min_rate_m_yr": 0.005}
+
+
+def edge_convergence(g, vel):
+ """Per directed edge s→d: closing speed (m/yr, >0 converging) and tangential slip speed."""
+ t, src, dst = g.edge_tangents, g.src, g.dst
+ along, tang = np.empty(len(dst)), np.empty(len(dst))
+ for s in edge_blocks(len(dst)): # per edge: the same values, small temporaries
+ rel = vel[dst[s]] - vel[src[s]]
+ along[s] = np.sum(rel * t[s], axis=1)
+ tang[s] = np.linalg.norm(rel - along[s][:, None] * t[s], axis=1)
+ return -along, tang
+
+
+def classify_boundaries(g, plate, vel, min_rate):
+ conv, tang = edge_convergence(g, vel)
+ s, d = g.src, g.dst
+ b = plate[s] != plate[d]
+ cnt = np.bincount(s[b], minlength=g.n)
+ c_mean = np.bincount(s[b], weights=conv[b], minlength=g.n) / np.maximum(cnt, 1)
+ t_mean = np.bincount(s[b], weights=tang[b], minlength=g.n) / np.maximum(cnt, 1)
+ other = np.full(g.n, -1, np.int16)
+ other[s[b]] = plate[d[b]]
+ typ = np.zeros(g.n, np.int8)
+ isb = cnt > 0
+ typ[isb] = TRANS
+ typ[isb & (c_mean > min_rate)] = CONV
+ typ[isb & (c_mean < -min_rate)] = DIV
+ rate = np.where(typ == CONV, c_mean, np.where(typ == DIV, -c_mean, np.where(isb, t_mean, 0.0)))
+ return typ, rate.astype(np.float32), other
+
+
+def _warp_labels(g, plate, seeds, P, seed, continental_kind):
+ """Domain warp: each cell takes the label found at a noise-displaced point → meandering boundaries.
+ Only same-kind swaps (cont↔cont, ocean↔ocean): ocean–continent margins stay where growth put them."""
+ if P["warp_km"] <= 0:
+ return plate
+ amp = P["warp_km"] / g.radius_km
+ disp = np.stack([fbm(g.xyz, seed + 101 + k, 3, P["warp_freq"]) for k in range(3)], axis=1)
+ disp /= max(float(disp.std()), 1e-12) # warp_km = RMS displacement per component
+ q = g.xyz + amp * disp
+ q /= np.linalg.norm(q, axis=1, keepdims=True)
+ _, j = cKDTree(g.xyz).query(q)
+ out = plate[j].astype(np.int16)
+ keep = continental_kind[out] != continental_kind[plate]
+ out[keep] = plate[keep]
+ out[seeds] = np.arange(len(seeds), dtype=np.int16)
+ return out
+
+
+def run(ctx) -> dict:
+ g = ctx.grid
+ P = params(ctx.cfg, "plates", DEFAULTS)
+ land, land_hint = ctx.need("sk_land", "m_land_hint")
+ plates = ctx.tect["plate"]
+ seeds = np.array([g.cell_index(*p["seed"]) for p in plates], dtype=np.int64)
+ if len(np.unique(seeds)) < len(seeds):
+ raise StageError("two plate seeds fall in the same cell; move one in tectonics.toml")
+ hint = np.clip(land + 0.5 * land_hint, 0.0, 1.0)
+ nz = fbm(g.xyz, ctx.seed + 11, octaves=4, freq=P["noise_freq"])
+ mult = np.where(hint[g.dst] > 0.5, P["land_cost"], 1.0) * (1.0 + P["noise"] * nz[g.dst])
+ _, src = nearest_source(g, seeds, g.edge_km * np.maximum(mult, 0.05))
+ order = np.argsort(seeds)
+ plate = order[np.searchsorted(seeds[order], src)].astype(np.int16)
+ kinds = np.array([p["kind"] == "continental" for p in plates])
+ plate = _warp_labels(g, plate, seeds, P, ctx.seed, kinds)
+ omegas = np.array([motion_to_omega(*p["seed"], *p["motion"], g.radius_km) for p in plates])
+ vel = velocity(g.xyz, omegas[plate], g.radius_km)
+ btype, brate, bother = classify_boundaries(g, plate, vel, P["min_rate_m_yr"])
+ return {"plate": plate, "vel": vel, "plate_continental": kinds,
+ "bnd_type": btype, "bnd_rate": brate, "bnd_other": bother}
diff --git a/mapgen/projections.py b/mapgen/projections.py
new file mode 100644
index 0000000..67efb19
--- /dev/null
+++ b/mapgen/projections.py
@@ -0,0 +1,150 @@
+"""Map projections of the equirectangular relief: Equal Earth, Mollweide (equal-area) and orthographic globes."""
+from __future__ import annotations
+
+import numpy as np
+from PIL import Image, ImageDraw
+
+from .sketch import sample_equirect
+from .sphere import east_north, latlon_to_xyz, xyz_to_latlon
+
+BACKGROUND = (16, 18, 24)
+_A1, _A2, _A3, _A4 = 1.340264, -0.081106, 0.000893, 0.003796
+_M = np.sqrt(3.0) / 2.0
+EE_XMAX = 2.0 * np.sqrt(3.0) * np.pi / (3.0 * _A1)
+EE_YMAX = _A1 * np.pi / 3 + _A2 * (np.pi / 3) ** 3 + _A3 * (np.pi / 3) ** 7 + _A4 * (np.pi / 3) ** 9
+MW_XMAX, MW_YMAX = 2.0 * np.sqrt(2.0), np.sqrt(2.0)
+
+
+def equal_earth_forward(lat, lon):
+ t = np.arcsin(_M * np.sin(np.radians(lat)))
+ d = _A1 + 3 * _A2 * t**2 + 7 * _A3 * t**6 + 9 * _A4 * t**8
+ x = 2 * np.sqrt(3.0) * np.radians(lon) * np.cos(t) / (3 * d)
+ y = _A1 * t + _A2 * t**3 + _A3 * t**7 + _A4 * t**9
+ return x, y
+
+
+def equal_earth_inverse(x, y):
+ t = np.asarray(y, dtype=np.float64) / _A1
+ for _ in range(12):
+ f = _A1 * t + _A2 * t**3 + _A3 * t**7 + _A4 * t**9 - y
+ t = t - f / (_A1 + 3 * _A2 * t**2 + 7 * _A3 * t**6 + 9 * _A4 * t**8)
+ d = _A1 + 3 * _A2 * t**2 + 7 * _A3 * t**6 + 9 * _A4 * t**8
+ lat = np.degrees(np.arcsin(np.clip(np.sin(t) / _M, -1, 1)))
+ lon = np.degrees(3 * np.asarray(x) * d / (2 * np.sqrt(3.0) * np.cos(t)))
+ return lat, lon
+
+
+def mollweide_forward(lat, lon):
+ phi = np.radians(lat)
+ t = phi.copy() if isinstance(phi, np.ndarray) else np.array(phi, dtype=np.float64)
+ for _ in range(30):
+ f = 2 * t + np.sin(2 * t) - np.pi * np.sin(phi)
+ t = t - f / np.maximum(2 + 2 * np.cos(2 * t), 1e-12)
+ return MW_XMAX / np.pi * np.radians(lon) * np.cos(t), MW_YMAX * np.sin(t)
+
+
+def mollweide_inverse(x, y):
+ t = np.arcsin(np.clip(np.asarray(y) / MW_YMAX, -1, 1))
+ lat = np.degrees(np.arcsin(np.clip((2 * t + np.sin(2 * t)) / np.pi, -1, 1)))
+ lon = np.degrees(np.pi * np.asarray(x) / (MW_XMAX * np.maximum(np.cos(t), 1e-12)))
+ return lat, lon
+
+
+def orthographic_inverse(x, y, lat0, lon0):
+ """Unit-disc view coords → lat/lon on the visible hemisphere centred on (lat0, lon0); ok = inside the disc."""
+ x, y = np.asarray(x, dtype=np.float64), np.asarray(y, dtype=np.float64)
+ rho2 = x**2 + y**2
+ ok = rho2 <= 1.0
+ z = np.sqrt(np.clip(1.0 - rho2, 0.0, 1.0))
+ c = latlon_to_xyz(np.array([lat0]), np.array([lon0]))
+ e, n = east_north(c)
+ p = x[..., None] * e[0] + y[..., None] * n[0] + z[..., None] * c[0]
+ lat, lon = xyz_to_latlon(p)
+ return lat, lon, ok
+
+
+def _sample_rgb(img, lat, lon):
+ """Bilinear RGB samples, read straight from the uint8 channels (each sampled value promotes to float64 exactly,
+ so no full-size float copy of a big image is needed)."""
+ return np.stack([sample_equirect(img[..., k], lat, lon) for k in range(3)], axis=-1)
+
+
+def reproject(img, proj, width):
+ """Equirectangular RGB → equal-area world map ('equal_earth' | 'mollweide')."""
+ xmax, ymax, inv = {"equal_earth": (EE_XMAX, EE_YMAX, equal_earth_inverse),
+ "mollweide": (MW_XMAX, MW_YMAX, mollweide_inverse)}[proj]
+ height = int(round(width * ymax / xmax))
+ xs = ((np.arange(width) + 0.5) / width * 2 - 1) * xmax
+ ys = (1 - (np.arange(height) + 0.5) / height * 2) * ymax
+ X, Y = np.meshgrid(xs, ys)
+ lat, lon = inv(X, Y)
+ ok = np.isfinite(lat) & np.isfinite(lon) & (np.abs(lon) <= 180.0)
+ rgb = _sample_rgb(img, np.where(ok, lat, 0.0), np.where(ok, lon, 0.0))
+ out = np.where(ok[..., None], rgb, np.array(BACKGROUND, dtype=np.float64))
+ return np.clip(np.round(out), 0, 255).astype(np.uint8)
+
+
+def globe(img, lat0, lon0, size):
+ """Orthographic view centred on (lat0, lon0), gentle limb darkening."""
+ v = (np.arange(size) + 0.5) / size * 2 - 1
+ X, Y = np.meshgrid(v, -v)
+ lat, lon, ok = orthographic_inverse(X, Y, lat0, lon0)
+ rgb = _sample_rgb(img, lat, lon) * (0.72 + 0.28 * np.sqrt(np.clip(1 - X**2 - Y**2, 0, 1)))[..., None]
+ out = np.where(ok[..., None], rgb, np.array(BACKGROUND, dtype=np.float64))
+ return np.clip(np.round(out), 0, 255).astype(np.uint8)
+
+
+def graticule(arr, proj, step=30):
+ """Draw a light lat/lon grid (every `step`°) onto an equal-area world map produced by `reproject`."""
+ fwd, xmax, ymax = {"equal_earth": (equal_earth_forward, EE_XMAX, EE_YMAX),
+ "mollweide": (mollweide_forward, MW_XMAX, MW_YMAX)}[proj]
+ h, w = arr.shape[:2]
+ im = Image.fromarray(arr)
+ draw = ImageDraw.Draw(im)
+ to_px = lambda x, y: list(zip(((x / xmax + 1) / 2 * w).tolist(), ((1 - y / ymax) / 2 * h).tolist()))
+ t = np.linspace(-90, 90, 181)
+ for lon in range(-180, 181, step):
+ draw.line(to_px(*fwd(t, np.full_like(t, float(lon)))), fill=(200, 210, 225), width=1)
+ s = np.linspace(-180, 180, 361)
+ for lat in range(-90 + step, 90, step):
+ draw.line(to_px(*fwd(np.full_like(s, float(lat)), s)), fill=(200, 210, 225), width=1)
+ return np.array(im)
+
+
+def continent_centres(g, ocean, min_share=0.03):
+ """Centroids (lat, lon) of land bodies holding ≥ min_share of all land, largest first."""
+ from .graph import components
+ land = ~np.asarray(ocean)
+ lab = components(g, land)
+ area = np.bincount(lab[land], weights=g.area_km2[land])
+ out = []
+ for k in np.argsort(-area):
+ if area[k] < min_share * area.sum():
+ break
+ m = lab == k
+ c = (g.xyz[m] * g.area_km2[m, None]).sum(axis=0)
+ la, lo = xyz_to_latlon(c[None])
+ out.append((float(la[0]), float(lo[0])))
+ return out
+
+
+def write_all(relief, pdir, g, ocean, width, globe_size=1024):
+ """Write proj_equal_earth.png, proj_mollweide.png, globe_*.png and globes_sheet.png into pdir."""
+ for proj in ("equal_earth", "mollweide"):
+ Image.fromarray(graticule(reproject(relief, proj, width), proj)).save(pdir / f"proj_{proj}.png")
+ views = [("north_pole", 90.0, 0.0), ("south_pole", -90.0, 0.0)]
+ views = [(f"continent_{i + 1}", la, lo) for i, (la, lo) in enumerate(continent_centres(g, ocean))] + views
+ tiles = []
+ for name, la, lo in views:
+ im = Image.fromarray(globe(relief, la, lo, globe_size))
+ im.save(pdir / f"globe_{name}.png")
+ tiles.append((f"{name} ({la:.0f}°, {lo:.0f}°)", im))
+ cols, t = 4, globe_size // 2
+ rows = (len(tiles) + cols - 1) // cols
+ sheet = Image.new("RGB", (cols * t, rows * (t + 16)), BACKGROUND)
+ draw = ImageDraw.Draw(sheet)
+ for k, (label, im) in enumerate(tiles):
+ x, y = (k % cols) * t, (k // cols) * (t + 16)
+ sheet.paste(im.resize((t, t)), (x, y + 16))
+ draw.text((x + 4, y + 2), label, fill=(230, 230, 230))
+ sheet.save(pdir / "globes_sheet.png")
diff --git a/mapgen/render.py b/mapgen/render.py
new file mode 100644
index 0000000..2a408e6
--- /dev/null
+++ b/mapgen/render.py
@@ -0,0 +1,526 @@
+"""Stage `render`: equirectangular rasters, cells.npz, metadata, previews, contact sheet."""
+from __future__ import annotations
+
+import colorsys
+import json
+
+import numpy as np
+from PIL import Image, ImageDraw
+from scipy.spatial import cKDTree
+
+from . import geo, projections, viewer_export
+from . import plateaus as PL
+from .crust import AGE_NAMES
+from .environment import LEGENDS, REGIONS, ZONES
+from .ice import ICE_NAMES
+from .noise import fbm
+from .seabed import MINERAL_NAMES, SEABED_NAMES
+from .sphere import east_north, latlon_to_xyz
+
+CONTINUOUS = {
+ "elevation": ("z_surface_m", 0.5, -12000.0, "m"),
+ "T_mean": ("T_mean", 0.01, -100.0, "degC"),
+ "T_range": ("T_range", 0.01, 0.0, "degC"),
+ "P_ann": ("P_ann", 0.5, 0.0, "mm/yr"),
+ "P_jun": ("P_jun", 0.5, 0.0, "mm/yr"),
+ "P_dec": ("P_dec", 0.5, 0.0, "mm/yr"),
+ "po2": ("po2_bar", 2e-4, 0.0, "bar"),
+ "gravity": ("gravity_g", 1e-4, 0.0, "g"),
+ "pressure": ("pressure_bar", 2e-4, 0.0, "bar"),
+ "o2_fraction": ("o2_fraction", 2e-5, 0.0, "fraction"),
+ "fire": ("fire_reactivity", 1e-4, 0.0, "x"),
+ "vent_potential": ("vent_potential", 1e-4, 0.0, "0-1"),
+ "bottom_temp": ("bottom_temp_c", 0.01, -10.0, "degC"),
+ "sediment": ("sediment_m", 0.5, 0.0, "m"),
+ "plant_height": ("plant_height_x", 2e-3, 0.0, "x"),
+ "sst": ("sst", 0.01, -100.0, "degC"),
+ "productivity": ("productivity", 1e-4, 0.0, "0-1"),
+ "current_speed": ("current_speed", 1e-4, 0.0, "m/s"),
+ "upwelling": ("upwelling", 0.05, -1600.0, "m/yr"),
+}
+CATEGORICAL = {"plates": "plate", "age_class": "age_class", "holdridge": "holdridge",
+ "seasonality": "seasonality", "landform": "landform", "lithology": "lithology",
+ "ground": "ground", "ice": "ice",
+ "seabed_type": "seabed_type", "seabed_mineral": "seabed_mineral", "deposits": "deposit_main"}
+DETAIL_M = np.array([60, 40, 150, 400, 80, 150, 300, 300, 300, 60, 30, 120], dtype=np.float64) # by landform
+CHUNK = 128
+COAST_DETAIL_M = 250.0
+
+
+def _by_rows(fn, v, dtype, tail=()):
+ """fn applied to CHUNK-row slices of v (fn elementwise): the same values as fn(v), without fn's full-size
+ float64 scratch (a raster ramp builds five H×W×3 float64 temporaries: ~4 GB at 8192 px)."""
+ v = np.asarray(v)
+ if v.ndim < 2 or v.shape[0] <= CHUNK:
+ return fn(v)
+ out = np.empty(v.shape + tuple(tail), dtype)
+ for y0 in range(0, v.shape[0], CHUNK):
+ out[y0:y0 + CHUNK] = fn(v[y0:y0 + CHUNK])
+ return out
+
+
+def encode(v, scale, offset):
+ return _by_rows(lambda x: np.clip(np.round((np.asarray(x, dtype=np.float64) - offset) / scale), 0, 65535)
+ .astype(np.uint16), v, np.uint16)
+
+
+def decode(raw, scale, offset):
+ return np.asarray(raw, dtype=np.float64) * scale + offset
+
+
+def _rows_xyz(rows, W, H):
+ lat = 90.0 - (rows + 0.5) / H * 180.0
+ lon = (np.arange(W) + 0.5) / W * 360.0 - 180.0
+ LA, LO = np.meshgrid(lat, lon, indexing="ij")
+ return latlon_to_xyz(LA.ravel(), LO.ravel())
+
+
+def _make_render_jit():
+ """Compiled ramp and sampling loops (numba optional; WORLDGEN_NO_JIT=1 turns it off): per pixel the same float
+ steps in the same order as the numpy statements they replace, so the same bytes, without the temporaries."""
+ import os
+ if os.environ.get("WORLDGEN_NO_JIT"):
+ return None
+ try:
+ import numba
+ except ImportError:
+ return None
+
+ @numba.njit(cache=True, nogil=True)
+ def ramp(x, lo, span, stops, out): # x: float64 (n,), out: uint8 (n, c)
+ m = stops.shape[0]
+ for j in range(x.shape[0]):
+ t = (x[j] - lo) / span
+ t = 0.0 if t < 0.0 else (1.0 if t > 1.0 else t)
+ t = t * (m - 1)
+ i = min(np.int64(t), m - 2)
+ f = t - i
+ for c in range(stops.shape[1]):
+ out[j, c] = np.uint8(np.int64(stops[i, c] * (1 - f) + stops[i + 1, c] * f))
+
+ @numba.njit(cache=True, nogil=True)
+ def sample(v, idx, wts, out): # out[p] = Σ_k v[idx[p,k]] * w[p,k], k left to right (as np.sum, k < 8)
+ for p in range(idx.shape[0]):
+ s = v[idx[p, 0]] * np.float64(wts[p, 0])
+ for k in range(1, idx.shape[1]):
+ s += v[idx[p, k]] * np.float64(wts[p, k])
+ out[p] = s
+ return ramp, sample
+
+
+_render_jit = _make_render_jit()
+
+
+def pixel_neighbours(g, W, H, k=3):
+ from .graph import workers
+ tree = cKDTree(g.xyz)
+ idx = np.empty((H, W, k), np.int32)
+ wts = np.empty((H, W, k), np.float32)
+ for y0 in range(0, H, CHUNK):
+ rows = np.arange(y0, min(H, y0 + CHUNK))
+ d, i = tree.query(_rows_xyz(rows, W, H), k=k, workers=workers()) # per point: the same answer
+ w = 1.0 / np.maximum(d, 1e-9) ** 2
+ w /= w.sum(axis=1, keepdims=True)
+ idx[rows] = i.reshape(len(rows), W, k)
+ wts[rows] = w.reshape(len(rows), W, k)
+ return idx, wts
+
+
+def sample_cont(v, idx, wts):
+ out = np.empty(idx.shape[:2])
+ if (_render_jit is not None and v.dtype == np.float64 and v.ndim == 1 and wts.dtype == np.float32
+ and 1 <= idx.shape[-1] < 8 and idx.shape == wts.shape):
+ k = idx.shape[-1]
+ _render_jit[1](np.ascontiguousarray(v), np.ascontiguousarray(idx).reshape(-1, k),
+ np.ascontiguousarray(wts).reshape(-1, k), out.reshape(-1))
+ return out
+ for y0 in range(0, idx.shape[0], CHUNK):
+ s = slice(y0, y0 + CHUNK)
+ out[s] = np.sum(v[idx[s]] * wts[s], axis=-1)
+ return out
+
+
+def sample_cat(v, idx):
+ return v[idx[..., 0]]
+
+
+def pixel_land(ocean_k, z):
+ """Pixel land mask: unanimous neighbour cells decide; at the coast (mixed) the sub-cell height does."""
+ all_sea = ocean_k.all(axis=-1)
+ all_land = ~ocean_k.any(axis=-1)
+ return all_land | (~all_sea & ~all_land & (z > 0))
+
+
+def hillshade(z, radius_km, az=315.0, alt=45.0, exag=4.0, low_memory=False):
+ """low_memory: the same values, CHUNK rows at a time (one halo row each side keeps the central differences)."""
+ if low_memory:
+ H = z.shape[0]
+ out = np.empty(z.shape)
+ for y0 in range(0, H, CHUNK):
+ a, b = max(y0 - 1, 0), min(y0 + CHUNK + 1, H)
+ part = _hillshade_rows(z[a:b], a, H, radius_km, az, alt, exag)
+ out[y0:min(y0 + CHUNK, H)] = part[y0 - a: y0 - a + min(CHUNK, H - y0)]
+ return out
+ return _hillshade_rows(z, 0, z.shape[0], radius_km, az, alt, exag)
+
+
+def _hillshade_rows(z, row0, H, radius_km, az, alt, exag):
+ """Hillshade of rows row0.. of an H-row raster (z holds those rows; edges of z use one-sided differences)."""
+ W = z.shape[1]
+ lat = 90.0 - (np.arange(row0, row0 + z.shape[0]) + 0.5) / H * 180.0
+ dy = np.pi * radius_km * 1000.0 / H
+ dx = 2 * np.pi * radius_km * 1000.0 * np.maximum(np.cos(np.radians(lat)), 0.01) / W
+ gy, gx = np.gradient(z)
+ dzdx = gx / dx[:, None] * exag
+ dzdn = -gy / dy * exag
+ norm = np.sqrt(dzdx**2 + dzdn**2 + 1.0)
+ a, b = np.radians(az), np.radians(alt)
+ L = (np.sin(a) * np.cos(b), np.cos(a) * np.cos(b), np.sin(b))
+ return np.clip((-dzdx * L[0] - dzdn * L[1] + L[2]) / norm, 0.0, 1.0)
+
+
+def _ramp(v, lo, hi, stops):
+ stops = np.asarray(stops, dtype=np.float64)
+
+ def part(x):
+ if (_render_jit is not None and isinstance(x, np.ndarray) and x.dtype == np.float64 and stops.ndim == 2
+ and len(stops) >= 2 and not np.isnan(x).any()):
+ out = np.empty(x.shape + (stops.shape[1],), np.uint8)
+ _render_jit[0](np.ascontiguousarray(x).reshape(-1), float(lo), float(hi - lo), stops,
+ out.reshape(-1, stops.shape[1]))
+ return out
+ t = np.clip((x - lo) / (hi - lo), 0, 1) * (len(stops) - 1)
+ i = np.minimum(t.astype(np.int64), len(stops) - 2)
+ f = (t - i)[..., None]
+ return (stops[i] * (1 - f) + stops[i + 1] * f).astype(np.uint8)
+ return _by_rows(part, v, np.uint8, (stops.shape[-1],))
+
+
+def holdridge_palette():
+ ramp = np.array([[216, 200, 160], [208, 196, 140], [200, 200, 120], [152, 168, 96], [106, 150, 80],
+ [70, 125, 68], [50, 105, 62], [37, 90, 56]], dtype=np.float64)
+ tint = {"polar": ((232, 236, 239), 0.9), "subpolar": ((170, 176, 160), 0.55), "boreal": ((70, 100, 80), 0.35),
+ "tropical": ((20, 90, 40), 0.15)}
+ cols = []
+ for r in REGIONS:
+ k = len(ZONES[r])
+ for j in range(k):
+ c = ramp[int(round((j / max(k - 1, 1)) * (len(ramp) - 1)))]
+ if r in tint:
+ c = c * (1 - tint[r][1]) + np.array(tint[r][0]) * tint[r][1]
+ cols.append(c)
+ return np.array(cols, dtype=np.uint8)
+
+
+def category_palette(n):
+ return np.array([[int(255 * c) for c in colorsys.hsv_to_rgb((i * 0.618034) % 1.0, 0.55, 0.9)]
+ for i in range(max(n, 1))], dtype=np.uint8)
+
+
+def _region_palette(cols):
+ """38 zone colours from per-region (dry, wet) colour pairs."""
+ out = []
+ for r in REGIONS:
+ dry, wet = (np.array(c, dtype=np.float64) for c in cols[r])
+ k = len(ZONES[r])
+ out += [dry + (wet - dry) * (j / max(k - 1, 1)) for j in range(k)]
+ return np.array(out, dtype=np.uint8)
+
+
+STYLES = { # alien palettes (config [render] style); None = Earth-like
+ "tidal-lock": {
+ "zones": {"polar": ((34, 38, 46), (34, 38, 46)), "subpolar": ((70, 72, 78), (96, 104, 112)),
+ "boreal": ((120, 96, 64), (150, 110, 60)), "cool temperate": ((170, 120, 60), (190, 140, 70)),
+ "warm temperate": ((110, 70, 44), (130, 86, 50)), "subtropical": ((46, 36, 34), (60, 44, 38)),
+ "tropical": ((22, 20, 22), (30, 26, 28))},
+ "ocean": [[4, 12, 16], [10, 34, 40], [40, 80, 84]], "lake": (60, 96, 104), "river": (70, 110, 118),
+ "ground": {6: (92, 86, 80)}, "sheet": (200, 222, 240), "sea_ice": (170, 200, 226), "sea_ice_seasonal": (120, 150, 170)},
+ "salt-mirror": {
+ "zones": {"polar": ((236, 240, 244), (236, 240, 244)), "subpolar": ((228, 228, 234), (214, 214, 228)),
+ "boreal": ((238, 236, 228), (208, 208, 224)), "cool temperate": ((242, 238, 226), (200, 204, 222)),
+ "warm temperate": ((240, 230, 204), (196, 204, 220)), "subtropical": ((238, 224, 186), (192, 206, 218)),
+ "tropical": ((234, 214, 166), (186, 206, 216))},
+ "ocean": [[70, 120, 130], [150, 195, 200], [215, 235, 235]], "lake": (150, 196, 204), "river": (150, 196, 204),
+ "ground": {6: (252, 250, 244)}, "sheet": (248, 250, 252), "sea_ice": (236, 244, 246), "sea_ice_seasonal": (220, 236, 238)},
+}
+
+
+def style_palette(style):
+ return None if style is None else STYLES[style]
+
+
+def relief_rgb(z, hs, zone, ground, ice, lake, land=None, vary=None, style=None, low_memory=False):
+ """vary: optional (brightness factor, tint) per pixel for open land (not lakes or ice): tint > 0 drier/yellower,
+ < 0 lusher (deeper green). style: a STYLES key (alien palette) or None. low_memory: the same pixels, made
+ CHUNK rows at a time (no full-size float64 scratch)."""
+ if low_memory:
+ out = np.empty(np.shape(z) + (3,), np.uint8)
+ for y0 in range(0, np.shape(z)[0], CHUNK):
+ s = slice(y0, y0 + CHUNK)
+ out[s] = relief_rgb(z[s], hs[s], zone[s], ground[s], ice[s], lake[s], None if land is None else land[s],
+ None if vary is None else tuple(np.asarray(v)[s] for v in vary), style)
+ return out
+ land = z > 0 if land is None else land
+ st = style_palette(style)
+ pal = holdridge_palette() if st is None else _region_palette(st["zones"])
+ rgb = pal[np.clip(zone, 0, 37)].astype(np.float64)
+ ocean = _ramp(z, -6500.0 if st is None else -1500.0, 0.0,
+ [[11, 43, 90], [30, 90, 150], [143, 198, 224]] if st is None else st["ocean"]).astype(np.float64)
+ rgb = np.where(land[..., None], rgb, ocean)
+ grounds = ((1, (111, 143, 106)), (2, (120, 128, 100)), (5, (63, 111, 74)), (6, (239, 233, 220))) if st is None \
+ else tuple(st["ground"].items())
+ for code, col in grounds:
+ rgb = np.where((land & (ground == code))[..., None], col, rgb)
+ if vary is not None:
+ bright, tint = (np.asarray(v, dtype=np.float64)[..., None] for v in vary)
+ shift = np.where(tint > 0, tint * np.array([1.0, 0.6, -0.8]), -tint * np.array([-0.9, -0.2, -0.6]))
+ if st is not None:
+ shift = np.abs(tint) * np.array([0.4, 0.4, 0.4]) * np.sign(tint)
+ rgb = np.where(land[..., None], rgb * bright + shift, rgb)
+ rgb = np.where((lake & land)[..., None], (79, 143, 192) if st is None else st["lake"], rgb)
+ rgb = np.where(np.isin(ice, [1, 2])[..., None], (244, 248, 251) if st is None else st["sheet"], rgb)
+ rgb = np.where((ice == 4)[..., None], (225, 235, 242) if st is None else st["sea_ice"], rgb)
+ rgb = np.where((ice == 3)[..., None], 0.5 * rgb + 0.5 * np.array([220, 232, 240] if st is None else st["sea_ice_seasonal"]), rgb)
+ shade = np.where(land, 0.55 + 0.45 * hs, 0.85 + 0.15 * hs)
+ return np.clip(rgb * shade[..., None], 0, 255).astype(np.uint8)
+
+
+RIVER_RGB = (58, 112, 176)
+
+
+def draw_rivers(rgb, g, recv, river, strahler, min_order=2, colour=RIVER_RGB):
+ """Draw river segments (cell centre → receiver) of order ≥ min_order; width grows with order."""
+ H, W = rgb.shape[:2]
+ im = Image.fromarray(rgb)
+ draw = ImageDraw.Draw(im)
+ x = (np.asarray(g.lon) + 180.0) / 360.0 * W - 0.5
+ y = (90.0 - np.asarray(g.lat)) / 180.0 * H - 0.5
+ cells = np.flatnonzero(river & (recv != np.arange(g.n)) & (strahler >= min_order))
+ for i in cells[np.argsort(strahler[cells])]:
+ j = recv[i]
+ if abs(x[i] - x[j]) > W / 2:
+ continue
+ width = max(1, int(round(int(strahler[i]) * W / 8192)))
+ draw.line([(x[i], y[i]), (x[j], y[j])], fill=tuple(colour), width=width)
+ return np.array(im)
+
+
+def draw_currents(rgb, g, current, ocean, per_row=60, colour=(255, 255, 255)):
+ """Arrows along the surface current on a lattice ≈ `per_row` across the map; length ∝ speed (1 m/s ≈ one
+ lattice step), sea only; currents under 2 cm/s get none."""
+ H, W = rgb.shape[:2]
+ step = W / per_row
+ current = np.asarray(current, dtype=np.float64)
+ ocean = np.asarray(ocean, bool)
+ e, n = east_north(g.xyz)
+ tree = cKDTree(g.xyz)
+ ys, xs = np.meshgrid(np.arange(step / 2, H, step), np.arange(step / 2, W, step), indexing="ij")
+ lat, lon = 90.0 - (ys.ravel() + 0.5) / H * 180.0, (xs.ravel() + 0.5) / W * 360.0 - 180.0
+ _, cell = tree.query(latlon_to_xyz(lat, lon))
+ ue, un = np.sum(current[cell] * e[cell], axis=1), np.sum(current[cell] * n[cell], axis=1)
+ sp = np.hypot(ue, un)
+ im = Image.fromarray(rgb)
+ draw = ImageDraw.Draw(im)
+ for x, y, a, b, s, ok in zip(xs.ravel(), ys.ravel(), ue, un, sp, ocean[cell]):
+ if not ok or s < 0.02:
+ continue
+ L = min(s, 1.5) * 0.9 * step
+ dx, dy = a / s * L, -b / s * L
+ x0, y0, x1, y1 = x - dx / 2, y - dy / 2, x + dx / 2, y + dy / 2
+ draw.line([(x0, y0), (x1, y1)], fill=tuple(colour), width=1)
+ for ang in (2.6, -2.6): # arrowhead: two barbs ±150°
+ c, sn = np.cos(ang), np.sin(ang)
+ draw.line([(x1, y1), (x1 + 0.35 * (c * dx - sn * dy), y1 + 0.35 * (sn * dx + c * dy))],
+ fill=tuple(colour), width=1)
+ return np.array(im)
+
+
+RAMPS = { # key: (unit, lo, hi, colour stops) — shared by previews, viewer textures and legends
+ "elevation": ("m", -6000.0, 6000.0, [[8, 30, 70], [30, 90, 150], [140, 200, 225], [60, 120, 60],
+ [150, 160, 90], [140, 110, 80], [235, 235, 235]]),
+ "T_mean": ("degC", -40.0, 40.0, [[40, 60, 160], [240, 240, 240], [180, 30, 30]]),
+ "T_range": ("degC", 0.0, 60.0, [[68, 1, 84], [33, 145, 140], [253, 231, 37]]),
+ "P": ("mm/yr", 0.0, 4000.0, [[150, 110, 60], [230, 220, 150], [60, 150, 70], [30, 70, 170]]),
+ "po2": ("bar", 0.08, 0.32, [[60, 40, 90], [240, 240, 240], [200, 90, 20]]),
+ "gravity": ("g", 0.3, 1.8, [[20, 120, 180], [240, 240, 240], [120, 40, 40]]),
+ "pressure": ("bar", 0.3, 2.5, [[40, 60, 120], [240, 240, 240], [150, 60, 30]]),
+ "o2_fraction": ("fraction", 0.10, 0.40, [[60, 40, 90], [240, 240, 240], [200, 90, 20]]),
+ "fire": ("x", 0.0, 1.5, [[40, 80, 160], [240, 240, 240], [220, 120, 20]]),
+ "vent_potential": ("0-1", 0.0, 1.0, [[10, 20, 50], [60, 90, 160], [250, 190, 60], [255, 250, 220]]),
+ "bottom_temp": ("degC", -2.0, 30.0, [[30, 40, 120], [80, 160, 200], [240, 200, 120]]),
+ "sediment": ("m", 0.0, 3000.0, [[40, 30, 60], [140, 110, 80], [240, 220, 170]]),
+ "plant_height": ("x", 0.5, 3.5, [[150, 120, 60], [240, 240, 240], [30, 110, 50]]),
+ "sst": ("degC", -2.0, 32.0, [[30, 40, 120], [60, 150, 200], [240, 240, 200], [220, 90, 40]]),
+ "productivity": ("0-1", 0.0, 1.0, [[10, 20, 60], [20, 110, 120], [120, 200, 90], [240, 240, 120]]),
+ "current_speed": ("m/s", 0.0, 1.0, [[10, 20, 50], [40, 90, 170], [120, 200, 230], [255, 255, 255]]),
+ "upwelling": ("m/yr", -200.0, 200.0, [[40, 60, 160], [240, 240, 240], [30, 140, 80]]),
+}
+VIEWER_LAYERS = [ # id, name, source (continuous raster name | categorical name | "relief")
+ ("relief", "Relief", "relief"), ("biomes", "Biomes (Holdridge)", "holdridge"),
+ ("elevation", "Elevation", "elevation"), ("temperature", "Mean temperature", "T_mean"),
+ ("rainfall", "Rainfall", "P_ann"), ("seasonality", "Seasonality", "seasonality"),
+ ("landform", "Landform", "landform"), ("ground", "Ground", "ground"), ("ice", "Ice", "ice"), ("deposits", "Mineral deposits", "deposits"),
+ ("plates", "Plates", "plates"), ("o2", "O₂ partial pressure", "po2"), ("gravity", "Gravity", "gravity"),
+ ("pressure", "Air pressure", "pressure"), ("fire", "Fire reactivity", "fire"),
+ ("seabed", "Sea-floor type", "seabed_type"), ("minerals", "Sea-floor minerals", "seabed_mineral"),
+ ("bottom_temp", "Bottom temperature", "bottom_temp"), ("sediment", "Sediment", "sediment"),
+ ("vent_potential", "Vent potential", "vent_potential"),
+ ("currents", "Ocean currents", "current_speed"), ("sst", "Sea-surface temperature", "sst"),
+ ("productivity", "Sea productivity", "productivity"),
+]
+
+
+def _ramp_key(name):
+ return "P" if name.startswith("P_") else name
+
+
+def _colorize(name, v):
+ _, lo, hi, stops = RAMPS[_ramp_key(name)]
+ return _ramp(v, lo, hi, stops)
+
+
+def _preview(img: Image.Image, width: int, nearest: bool) -> Image.Image:
+ return img.resize((width, width // 2), Image.NEAREST if nearest else Image.LANCZOS)
+
+
+def contact_sheet(tiles, tile_w):
+ cols = 3
+ th = tile_w // 2 + 14
+ rows = (len(tiles) + cols - 1) // cols
+ sheet = Image.new("RGB", (cols * tile_w, rows * th), (20, 20, 24))
+ draw = ImageDraw.Draw(sheet)
+ for k, (name, im) in enumerate(tiles):
+ x, y = (k % cols) * tile_w, (k // cols) * th
+ sheet.paste(im.resize((tile_w, tile_w // 2)), (x, y + 14))
+ draw.text((x + 4, y + 1), name, fill=(235, 235, 235))
+ return sheet
+
+
+class _Writer:
+ """Saves files on a background thread (PNG/zlib encoding releases the GIL) while the next layer is computed;
+ at most `depth` waiting, so memory stays bounded. The files are the same bytes as saved in line."""
+ def __init__(self, threads: int = 2, depth: int = 4):
+ from concurrent.futures import ThreadPoolExecutor
+ self.ex, self.pending, self.depth = ThreadPoolExecutor(threads), [], depth
+
+ def __call__(self, fn, *args, **kw):
+ while len(self.pending) >= self.depth:
+ self.pending.pop(0).result()
+ self.pending.append(self.ex.submit(fn, *args, **kw))
+
+ def close(self):
+ try:
+ for f in self.pending:
+ f.result()
+ finally:
+ self.ex.shutdown(wait=True, cancel_futures=True)
+
+
+def run(ctx) -> dict:
+ writer = _Writer()
+ try:
+ return _run(ctx, writer)
+ finally:
+ writer.close()
+
+
+def _run(ctx, save) -> dict:
+ g, cfg, d = ctx.grid, ctx.cfg, ctx.data
+ W = int(cfg["build"]["raster_width"])
+ if ctx.res < int(cfg["build"]["res_final"]):
+ W = int(cfg["build"].get("dev_raster_width", W))
+ H = W // 2
+ pw = int(cfg["build"]["preview_width"])
+ out = ctx.out_dir or ctx.root / "out" / f"r{ctx.res}"
+ rdir = out / "raster"
+ pdir = ctx.preview_dir or ctx.root / "previews" / f"r{ctx.res}"
+ rdir.mkdir(parents=True, exist_ok=True)
+ pdir.mkdir(parents=True, exist_ok=True)
+ idx, wts = pixel_neighbours(g, W, H)
+ legends = {**LEGENDS, "age_class": AGE_NAMES, "ice": ICE_NAMES, "plates": [p["id"] for p in ctx.tect["plate"]],
+ "seabed_type": SEABED_NAMES, "seabed_mineral": MINERAL_NAMES}
+ meta = {"width": W, "height": H, "projection": "equirectangular", "radius_km": g.radius_km,
+ "units": cfg.get("units", {}), "continuous": {}, "categorical": {}}
+ tiles = []
+ cats = {name: sample_cat(np.asarray(d[key]), idx) for name, key in CATEGORICAL.items()}
+
+ z = sample_cont(np.asarray(d["z_surface_m"], dtype=np.float64), idx, wts)
+ amp = DETAIL_M[np.clip(cats["landform"], 0, len(DETAIL_M) - 1)]
+ for y0 in range(0, H, CHUNK):
+ rows = np.arange(y0, min(H, y0 + CHUNK))
+ z[rows] += amp[rows] * fbm(_rows_xyz(rows, W, H), ctx.seed + 61, 5, 64.0).reshape(len(rows), W)
+ lake = sample_cat(np.asarray(d["lake"]), idx)
+ ocean_cells = np.asarray(d["ocean"]) if "ocean" in d else np.asarray(d["z_surface_m"]) <= 0
+ ocean_k = ocean_cells[idx]
+ coast = ocean_k.any(axis=-1) & ~ocean_k.all(axis=-1)
+ for y0 in range(0, H, CHUNK): # extra fine detail where the coastline runs
+ rows = np.arange(y0, min(H, y0 + CHUNK))
+ cz = COAST_DETAIL_M * fbm(_rows_xyz(rows, W, H), ctx.seed + 67, 5, 256.0).reshape(len(rows), W)
+ z[rows] += np.where(coast[rows], cz, 0.0)
+ land = pixel_land(ocean_k, z)
+ if "lake_level_m" in d: # the water surface for drawing: lake cells at their level (beds stay in `elevation`)
+ lev = np.asarray(d["lake_level_m"], dtype=np.float64)
+ dz = sample_cont(np.where(np.isfinite(lev), lev - np.asarray(d["z_surface_m"], dtype=np.float64), 0.0), idx, wts)
+ save(Image.fromarray(encode(z + dz, *CONTINUOUS["elevation"][1:3])).save, rdir / "surface.png")
+ meta["continuous"]["surface"] = {"file": "surface.png", "scale": CONTINUOUS["elevation"][1],
+ "offset": CONTINUOUS["elevation"][2], "unit": "m"}
+ hs = hillshade(z, g.radius_km, low_memory=ctx.low_memory)
+ style = cfg.get("render", {}).get("style")
+ rgb = relief_rgb(z, hs, cats["holdridge"], cats["ground"], cats["ice"], lake, land, style=style,
+ low_memory=ctx.low_memory)
+ rgb = draw_rivers(rgb, g, np.asarray(d["recv"]), np.asarray(d["river"]), np.asarray(d["strahler"]),
+ colour=RIVER_RGB if style is None else STYLES[style]["river"])
+ relief = Image.fromarray(rgb)
+ projections.write_all(rgb, pdir, g, ocean_cells, pw, globe_size=max(pw // 2, 64))
+ save(relief.copy().save, rdir / "relief.png")
+ tiles.append(("relief", _preview(relief, pw, False)))
+
+ vdir = out / "viewer"
+ by_src = {} # viewer layers are written as soon as their colours exist
+ for vid, vname, src in VIEWER_LAYERS:
+ by_src.setdefault(src, []).append((vid, vname))
+ ventries = {}
+
+ def emit(src, rgb_, legend):
+ for vid, vname in by_src.get(src, []):
+ ventries[vid] = viewer_export.write_layer(vdir, {"id": vid, "name": vname, "rgb": rgb_, "legend": legend})
+
+ emit("relief", rgb, None)
+ for name, (key, scale, offset, unit) in CONTINUOUS.items():
+ v = z if name == "elevation" else sample_cont(np.asarray(d[key], dtype=np.float64), idx, wts)
+ save(Image.fromarray(encode(v, scale, offset)).save, rdir / f"{name}.png")
+ meta["continuous"][name] = {"file": f"{name}.png", "scale": scale, "offset": offset, "unit": unit}
+ crgb = _colorize(name, v)
+ if name == "current_speed" and "current" in d:
+ crgb = draw_currents(crgb, g, d["current"], ocean_cells)
+ if name != "elevation":
+ tiles.append((name, _preview(Image.fromarray(crgb), pw, False)))
+ if name in by_src:
+ unit_, lo, hi, stops = RAMPS[_ramp_key(name)]
+ emit(name, crgb, viewer_export.continuous_legend(unit_, lo, hi, stops))
+ del crgb
+ for name, v in cats.items():
+ save(Image.fromarray(v.astype(np.uint8), "L").save, rdir / f"{name}.png")
+ meta["categorical"][name] = {"file": f"{name}.png", "legend": legends[name]}
+ pal = holdridge_palette() if name == "holdridge" else category_palette(len(legends[name]))
+ crgb = pal[np.clip(v, 0, len(pal) - 1)]
+ tiles.append((name, _preview(Image.fromarray(crgb), pw, True)))
+ emit(name, crgb, viewer_export.categorical_legend(legends[name], pal))
+ del crgb
+ (out / "fields.json").write_text(json.dumps(meta, indent=1))
+
+ per_cell = {k: v for k, v in d.items() if isinstance(v, np.ndarray) and v.shape[:1] == (g.n,)}
+ save(lambda: np.savez_compressed(out / "cells.npz", **per_cell))
+ (out / "cells_meta.json").write_text(json.dumps(
+ {"res": ctx.res, "radius_km": g.radius_km, "n_cells": g.n, "units": cfg.get("units", {}),
+ "planet": {**cfg["planet"], **({"sun_lock": cfg["climate"].get("lock_at", [0.0, 0.0])}
+ if cfg.get("climate", {}).get("lock") else {})}, "name": cfg.get("render", {}).get("name", "World"), "style": style,
+ "legends": legends, "fields": sorted(per_cell),
+ "plateaus": [PL.features(p, ctx.seed, g.radius_km) for p in ctx.tect.get("plateau", [])]}, indent=1))
+
+ for name, im in tiles:
+ im.save(pdir / f"{name}.png")
+ contact_sheet(tiles, max(pw // 3, 64)).save(pdir / "contact_sheet.png")
+ viewer_export.write_index(vdir, [ventries[vid] for vid, _, _ in VIEWER_LAYERS])
+ geo.write_all(ctx, out, land, lake)
+ return {}
diff --git a/mapgen/seabed.py b/mapgen/seabed.py
new file mode 100644
index 0000000..7715c44
--- /dev/null
+++ b/mapgen/seabed.py
@@ -0,0 +1,95 @@
+"""Stage `seabed`: sea-floor data for every ocean cell — vent potential,
+sea-floor type, minerals, bottom temperature, sediment. Land cells get 0 / "—" (the fields describe the sea floor)."""
+from __future__ import annotations
+
+import numpy as np
+from scipy.spatial import cKDTree
+
+from . import plateaus as PL
+from .config import params
+from .elevation import hotspot_track
+from .graph import distance_to
+from .noise import fbm
+from .sphere import latlon_to_xyz
+
+(SB_NONE, SB_TERRIGENOUS, SB_CARBONATE, SB_CLAY, SB_BASALT, SB_VOLCANIC, SB_CONTINENTAL, SB_VENTS,
+ SB_TRENCH) = range(9)
+SEABED_NAMES = ["—", "terrigenous sediment", "carbonate ooze", "pelagic clay", "young basalt", "volcanic",
+ "continental / plateau", "vent field", "trench"]
+MI_NONE, MI_SULFIDES, MI_NODULES, MI_COBALT, MI_PHOSPHORITE = range(5)
+MINERAL_NAMES = ["none", "polymetallic sulfides", "manganese nodules", "cobalt crusts", "phosphorite"]
+DEFAULTS = {"ridge_km": 100.0, "back_arc_km": 300.0, "back_arc_width_km": 150.0, "back_arc": 0.6,
+ "hotspot_km": 150.0, "hotspot_spacing_km": 150.0, "terrigenous_km": 300.0, "carbonate_depth_m": 4500.0,
+ "carbonate_t_c": 10.0, "young_myr": 10.0, "trench_km": 80.0, "trench_depth_m": 5000.0,
+ "vent_field": 0.8, "sulfides": 0.7, "volcanic": 0.3, "thermocline_m": 800.0}
+
+
+def _cells_near(tree, radius_km, p, reach_km):
+ return np.asarray(tree.query_ball_point(p, 2.0 * np.sin(min(np.pi, reach_km / radius_km) / 2.0)), dtype=np.int64)
+
+
+def _bump(v, g, tree, p, r_km, amp):
+ i = _cells_near(tree, g.radius_km, p, 3.0 * r_km)
+ if len(i):
+ d = g.radius_km * np.arccos(np.clip(g.xyz[i] @ p, -1.0, 1.0))
+ v[i] = np.maximum(v[i], amp * np.exp(-(d / r_km) ** 2))
+
+
+def volcanic_potential(g, tect: dict, vel, seed: int, P: dict):
+ """0–1 per cell from hotspot chains (strongest at the active end) and plateau volcanic fields."""
+ tree, v = cKDTree(g.xyz), np.zeros(g.n)
+ for h in tect.get("hotspot", []):
+ for p, s in hotspot_track(g, h, vel, P["hotspot_spacing_km"]):
+ _bump(v, g, tree, p, P["hotspot_km"], 1.0 - s / h["length_km"])
+ for pl in tect.get("plateau", []):
+ f, vent = PL.features(pl, seed, g.radius_km), float(pl.get("vent", 1.0))
+ _bump(v, g, tree, latlon_to_xyz(*f["hotspot"]), 200.0, vent)
+ for c in f["cones"]:
+ _bump(v, g, tree, latlon_to_xyz(c["lat"], c["lon"]), c["radius_km"] + 30.0, 0.8 * vent)
+ for c in f["calderas"]:
+ _bump(v, g, tree, latlon_to_xyz(c["lat"], c["lon"]), c["radius_km"] + 30.0, vent)
+ return v
+
+
+def run(ctx) -> dict:
+ g = ctx.grid
+ P = params(ctx.cfg, "seabed", DEFAULTS)
+ z, ocean, cont, age, d_div, d_over, d_sub, t_mean, vel = ctx.need(
+ "elevation_eroded_m", "ocean", "continental", "ocean_age_myr", "d_div_km", "d_over_km", "d_sub_km", "T_mean",
+ "vel")
+ z, ocean, cont = np.asarray(z, dtype=np.float64), np.asarray(ocean, bool), np.asarray(cont, bool)
+ age, t_mean = np.asarray(age, dtype=np.float64), np.asarray(t_mean, dtype=np.float64)
+ plateau_id = np.asarray(ctx.data.get("plateau_id", np.full(g.n, -1)))
+ depth = np.maximum(-z, 0.0)
+ volc = volcanic_potential(g, ctx.tect, np.asarray(vel), ctx.seed, P)
+ ridge = np.exp(-(np.asarray(d_div) / P["ridge_km"]) ** 2)
+ back_arc = P["back_arc"] * np.exp(-((np.asarray(d_over) - P["back_arc_km"]) / P["back_arc_width_km"]) ** 2)
+ cluster = np.clip(0.5 + 1.5 * fbm(g.xyz, ctx.seed + 301, 4, 30.0), 0.0, 1.0) # vents come in fields
+ vent = np.where(ocean, np.maximum.reduce([ridge, back_arc, volc]) * (0.5 + 0.5 * cluster), 0.0)
+ d_land = distance_to(g, ~ocean) if (~ocean).any() else np.full(g.n, np.inf)
+
+ t = np.full(g.n, SB_CLAY, np.int8)
+ t[(depth < P["carbonate_depth_m"]) & (t_mean > P["carbonate_t_c"])] = SB_CARBONATE
+ t[cont] = SB_CONTINENTAL
+ t[d_land < P["terrigenous_km"]] = SB_TERRIGENOUS
+ t[~cont & (age < P["young_myr"])] = SB_BASALT
+ t[volc > P["volcanic"]] = SB_VOLCANIC
+ t[(np.asarray(d_sub) < P["trench_km"]) & (depth > P["trench_depth_m"])] = SB_TRENCH
+ t[vent > P["vent_field"]] = SB_VENTS
+ t[~ocean] = SB_NONE
+
+ m = np.zeros(g.n, np.int8)
+ m[(t == SB_CLAY) & (age > 30.0) & (d_land > 1000.0) & (depth > 4000.0)] = MI_NODULES
+ m[((t == SB_VOLCANIC) | (plateau_id >= 0)) & (depth >= 800.0) & (depth <= 2500.0)] = MI_COBALT
+ m[cont & (depth < 500.0)] = MI_PHOSPHORITE
+ m[vent > P["sulfides"]] = MI_SULFIDES
+ m[~ocean] = MI_NONE
+
+ deep = 1.0 + 3.0 * np.cos(np.radians(g.lat)) ** 2 # ≈ 1 °C polar … 4 °C tropical
+ w = np.clip((P["thermocline_m"] - depth) / P["thermocline_m"], 0.0, 1.0) # shallow floors: towards the surface
+ bottom = np.where(ocean, deep + w * (np.maximum(t_mean, -1.8) - deep), 0.0)
+ terr = 2500.0 * np.exp(-d_land / 250.0) # aprons off the continents
+ sed = np.where(cont, 300.0 + terr, np.minimum(5.0 * age, 800.0) + terr) # pelagic rain ≈ 5 m per Myr
+ sed = np.where(ocean, sed, 0.0)
+ return {"vent_potential": vent.astype(np.float32), "seabed_type": t, "seabed_mineral": m,
+ "bottom_temp_c": bottom.astype(np.float32), "sediment_m": sed.astype(np.float32)}
diff --git a/mapgen/sketch.py b/mapgen/sketch.py
new file mode 100644
index 0000000..a0249bf
--- /dev/null
+++ b/mapgen/sketch.py
@@ -0,0 +1,121 @@
+"""Stage `sketch`: sample the sketch + user masks onto cells."""
+from __future__ import annotations
+
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+from . import zones as ZN
+from .config import params
+from .noise import fbm
+from .pipeline import StageError
+from .sphere import latlon_to_xyz, xyz_to_latlon
+
+W, H = 2000, 1000
+SKETCH = ("land", "mountains", "desert", "rainforest", "trench")
+MASKS = ("land_hint", "mountain_hint", "o2_zones", "gravity_zones", "lock")
+DEFAULTS = {"warp_km": 1000.0, "warp_freq": 1.2, "detail_warp_km": 200.0, "detail_warp_freq": 5.0, "moves": []}
+
+
+def load_png01(path: Path) -> np.ndarray:
+ return np.array(Image.open(path).convert("L"), dtype=np.float64) / 255.0
+
+
+def load_mask(path: Path) -> np.ndarray:
+ """Grayscale override mask → [−1, 1]; mid-grey (128 / 32768), transparency and missing detail = neutral 0."""
+ try:
+ im = Image.open(path)
+ im.load()
+ except (OSError, ValueError) as e:
+ raise StageError(f"cannot read mask {path.name}: {e}") from e
+ if im.mode in ("I", "I;16", "I;16B", "I;16L"):
+ v = (np.array(im).astype(np.float64) - 32768.0) / 32768.0
+ dead = 1.0 / 32768.0
+ else:
+ la = np.array(im.convert("LA")).astype(np.float64)
+ v = np.where(la[..., 1] > 0, (la[..., 0] - 127.5) / 127.5, 0.0)
+ dead = 1.0 / 255.0
+ return np.clip(np.where(np.abs(v) <= dead, 0.0, v), -1.0, 1.0)
+
+
+def sample_equirect(img: np.ndarray, lat, lon) -> np.ndarray:
+ h, w = img.shape
+ x = (np.asarray(lon, dtype=np.float64) + 180.0) / 360.0 * w - 0.5
+ y = (90.0 - np.asarray(lat, dtype=np.float64)) / 180.0 * h - 0.5
+ x0 = np.floor(x).astype(np.int64)
+ y0 = np.floor(y).astype(np.int64)
+ fx, fy = x - x0, y - y0
+ xa, xb = x0 % w, (x0 + 1) % w
+ ya, yb = np.clip(y0, 0, h - 1), np.clip(y0 + 1, 0, h - 1)
+ top = img[ya, xa] * (1 - fx) + img[ya, xb] * fx
+ bot = img[yb, xa] * (1 - fx) + img[yb, xb] * fx
+ return top * (1 - fy) + bot * fy
+
+
+def rotation(a, b):
+ """3×3 rotation carrying [lat, lon] a onto b along the great circle (Rodrigues)."""
+ pa, pb = latlon_to_xyz(*np.array(a, dtype=np.float64)), latlon_to_xyz(*np.array(b, dtype=np.float64))
+ k = np.cross(pa, pb)
+ s, c = np.linalg.norm(k), float(np.dot(pa, pb))
+ if s < 1e-12:
+ return np.eye(3)
+ k /= s
+ K = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]])
+ return np.eye(3) + s * K + (1 - c) * K @ K
+
+
+def continent_mask(land, lat, lon):
+ """Pixels of the sketch landmass containing (lat, lon), 2 px coastal fringe included; lon wraps."""
+ from scipy import ndimage
+ lab, _ = ndimage.label(land)
+ for a, b in zip(lab[:, 0], lab[:, -1]):
+ if a and b and a != b:
+ lab[lab == b] = a
+ h, w = land.shape
+ y, x = min(max(int((90.0 - lat) / 180.0 * h), 0), h - 1), int((lon + 180.0) / 360.0 * w) % w
+ if not lab[y, x]:
+ raise StageError(f"sketch move: no sketch land at [{lat}, {lon}]")
+ return ndimage.binary_dilation(lab == lab[y, x], iterations=2)
+
+
+def warped_latlon(g, P, seed):
+ """Sample positions displaced by a two-scale domain warp (RMS ≈ warp_km, detail_warp_km)."""
+ p = g.xyz.copy()
+ for amp, freq, s in ((P["warp_km"], P["warp_freq"], 201), (P["detail_warp_km"], P["detail_warp_freq"], 211)):
+ if amp <= 0:
+ continue
+ disp = np.stack([fbm(g.xyz, seed + s + k, 4, freq) for k in range(3)], axis=1)
+ p = p + (amp / g.radius_km) * disp / max(float(disp.std()), 1e-12)
+ return xyz_to_latlon(p)
+
+
+def run(ctx) -> dict:
+ g = ctx.grid
+ P = params(ctx.cfg, "sketch", DEFAULTS)
+ sk = ctx.root / "sketch"
+ missing = [n for n in SKETCH if not (sk / f"{n}.png").exists()]
+ if missing:
+ raise StageError(f"sketch files missing {missing}: draw them or run `mapgen.py new-world` first")
+ lat, lon = warped_latlon(g, P, ctx.seed) # the drawing is a loose guide: warp it (user masks are not)
+ imgs = {n: load_png01(sk / f"{n}.png") for n in SKETCH}
+ moved = [] # [[sketch.moves]]: rotate whole landmasses across the sphere
+ for mv in P["moves"]:
+ comp = continent_mask(imgs["land"] > 0.5, *mv["at"])
+ moved.append((rotation(mv["at"], mv["to"]), {n: np.where(comp, im, 0.0) for n, im in imgs.items()}))
+ imgs = {n: np.where(comp, 0.0, im) for n, im in imgs.items()}
+ p = latlon_to_xyz(lat, lon)
+ out = {}
+ for n in SKETCH:
+ v = sample_equirect(imgs[n], lat, lon)
+ for rot, parts in moved:
+ qlat, qlon = xyz_to_latlon(p @ rot) # rot.T applied to row vectors
+ v = np.maximum(v, sample_equirect(parts[n], qlat, qlon))
+ out[f"sk_{n}"] = v
+ for m in MASKS:
+ p = ctx.root / "masks" / f"{m}.png"
+ out[f"m_{m}"] = sample_equirect(load_mask(p), g.lat, g.lon) if p.exists() else np.zeros(g.n)
+ zo2, zg = ZN.masks(g.xyz, ctx.tect.get("zone", []), ctx.seed, g.radius_km) # config zones on top of the masks
+ out["m_o2_zones"] = np.clip(out["m_o2_zones"] + zo2, -1.0, 1.0)
+ out["m_gravity_zones"] = np.clip(out["m_gravity_zones"] + zg, -1.0, 1.0)
+ return out
diff --git a/mapgen/sphere.py b/mapgen/sphere.py
new file mode 100644
index 0000000..394b9ef
--- /dev/null
+++ b/mapgen/sphere.py
@@ -0,0 +1,68 @@
+"""Unit-sphere geometry and plate kinematics. Vectors are (..., 3); lat/lon in degrees."""
+from __future__ import annotations
+
+import numpy as np
+
+
+def latlon_to_xyz(lat, lon):
+ la, lo = np.radians(lat), np.radians(lon)
+ return np.stack([np.cos(la) * np.cos(lo), np.cos(la) * np.sin(lo), np.sin(la)], axis=-1)
+
+
+def xyz_to_latlon(p):
+ p = p / np.linalg.norm(p, axis=-1, keepdims=True)
+ return np.degrees(np.arcsin(np.clip(p[..., 2], -1, 1))), np.degrees(np.arctan2(p[..., 1], p[..., 0]))
+
+
+def east_north(p):
+ """Local unit east/north vectors. At the poles east is taken as +y (finite, arbitrary)."""
+ p = np.asarray(p, dtype=np.float64)
+ e = np.stack([-p[..., 1], p[..., 0], np.zeros_like(p[..., 0])], axis=-1) # z × p
+ ne = np.linalg.norm(e, axis=-1, keepdims=True)
+ polar = ne[..., 0] < 1e-12
+ e = np.where(polar[..., None], np.array([0.0, 1.0, 0.0]), e / np.maximum(ne, 1e-300))
+ n = np.cross(p, e)
+ return e, n
+
+
+def tangent_dir(p, q):
+ """Unit tangent at p pointing along the great circle toward q."""
+ t = q - np.sum(p * q, axis=-1, keepdims=True) * p
+ return t / np.maximum(np.linalg.norm(t, axis=-1, keepdims=True), 1e-15)
+
+
+def gc_dist_km(p, q, radius_km):
+ return radius_km * np.arccos(np.clip(np.sum(p * q, axis=-1), -1.0, 1.0))
+
+
+def motion_to_omega(lat, lon, azimuth_deg, speed_cm_yr, radius_km):
+ """Angular velocity (rad/yr) that moves the point (lat, lon) along azimuth at speed."""
+ p = latlon_to_xyz(np.array([lat]), np.array([lon]))
+ e, n = east_north(p)
+ az = np.radians(azimuth_deg)
+ v = (np.sin(az) * e[0] + np.cos(az) * n[0]) * (speed_cm_yr / 100.0) # m/yr
+ return np.cross(p[0], v) / (radius_km * 1000.0)
+
+
+def velocity(p, omega, radius_km):
+ """Surface velocity (m/yr) of points p on plates with angular velocity omega (broadcast)."""
+ return np.cross(omega, p) * (radius_km * 1000.0)
+
+
+def rotate_about(p, v, ang):
+ """Rotate tangent vectors v about normals p by ang radians (counter-clockwise seen from outside)."""
+ ang = np.asarray(ang, dtype=np.float64)[..., None]
+ return v * np.cos(ang) + np.cross(p, v) * np.sin(ang)
+
+
+def azimuth_deg(center, p):
+ """Bearing (deg clockwise from north, [0, 360)) from center (3,) to points p (..., 3)."""
+ e, n = east_north(center[None])
+ t = tangent_dir(np.broadcast_to(center, p.shape), p)
+ return np.degrees(np.arctan2(t @ e[0], t @ n[0])) % 360.0
+
+
+def great_circle_point(p0, t0, dist_km, radius_km):
+ """Point reached from p0 moving dist_km along unit tangent t0."""
+ a = dist_km / radius_km
+ return np.cos(a) * p0 + np.sin(a) * t0
diff --git a/mapgen/testdata/world.toml b/mapgen/testdata/world.toml
new file mode 100644
index 0000000..e650e39
--- /dev/null
+++ b/mapgen/testdata/world.toml
@@ -0,0 +1,31 @@
+# Test fixture world (the unit tests' planet); not an example — see example/config/world.toml.
+[planet]
+radius_km = 12742.0
+gravity_g = 1.05
+day_hours = 31.149
+year_days = 216
+tilt_deg = 20.0
+sea_level_pressure_bar = 1.0
+scale_height_km = 8.0
+o2_fraction = 0.21
+
+[units]
+span_m = 0.331013
+moment_s = 2.403473
+league_km = 15.4437
+
+[build]
+seed = 1296
+res_dev = 4
+res_final = 5
+raster_width = 8192
+dev_raster_width = 2048
+preview_width = 2048
+land_fraction = 0.19
+
+[climate]
+hadley_edge_deg = 20.0
+ferrel_edge_deg = 55.0
+
+[sketch]
+moves = []
diff --git a/mapgen/testing.py b/mapgen/testing.py
new file mode 100644
index 0000000..b70e5fe
--- /dev/null
+++ b/mapgen/testing.py
@@ -0,0 +1,59 @@
+"""Test fixtures shared with tools built on mapgen (e.g. a viewer's tests): a tiny two-continent world at H3 res 2."""
+from __future__ import annotations
+
+import atexit
+import functools
+import re
+import shutil
+import tempfile
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+FIXTURE_TOML = Path(__file__).resolve().parent / "testdata" / "world.toml"
+
+TECT = """
+[[plate]]
+id = "a"
+seed = [10.0, -30.0]
+kind = "continental"
+motion = [90.0, 4.0]
+[[plate]]
+id = "b"
+seed = [10.0, 30.0]
+kind = "continental"
+motion = [270.0, 4.0]
+[[plate]]
+id = "c"
+seed = [0.0, 150.0]
+kind = "oceanic"
+motion = [0.0, 3.0]
+"""
+
+
+def small_world(tmp: Path) -> None:
+ (tmp / "config").mkdir()
+ w = FIXTURE_TOML.read_text()
+ w = w.replace("raster_width = 8192", "raster_width = 256").replace("preview_width = 2048", "preview_width = 128")
+ w = w.replace("dev_raster_width = 2048", "dev_raster_width = 256")
+ w = re.sub(r"moves = \[.*?\n\]", "moves = []", w, flags=re.S)
+ (tmp / "config" / "world.toml").write_text(w)
+ (tmp / "config" / "tectonics.toml").write_text(TECT)
+ (tmp / "sketch").mkdir()
+ lat = 90 - (np.arange(100) + 0.5) * 1.8
+ lon = (np.arange(200) + 0.5) * 1.8 - 180
+ LA, LO = np.meshgrid(lat, lon, indexing="ij")
+ land = ((np.abs(LA - 10) < 35) & (np.abs(LO) < 70)).astype(np.uint8) * 255
+ for n in ("land", "mountains", "desert", "rainforest", "trench"):
+ Image.fromarray(land if n == "land" else np.zeros_like(land), "L").save(tmp / "sketch" / f"{n}.png")
+
+
+@functools.lru_cache(maxsize=1)
+def built_world() -> Path:
+ from mapgen import pipeline as P
+ tmp = Path(tempfile.mkdtemp(prefix="worldgen-fixture-"))
+ atexit.register(shutil.rmtree, tmp, True) # 5 MB per build: never leave them in /tmp
+ small_world(tmp)
+ P.build(tmp, 2, log=lambda m: None)
+ return tmp
diff --git a/mapgen/viewer_export.py b/mapgen/viewer_export.py
new file mode 100644
index 0000000..e6f51b4
--- /dev/null
+++ b/mapgen/viewer_export.py
@@ -0,0 +1,39 @@
+"""Viewer textures: colourised equirectangular JPEG layers + layers.json."""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+
+def hex_color(c) -> str:
+ return "#%02x%02x%02x" % tuple(int(v) for v in list(c)[:3])
+
+
+def categorical_legend(names, palette) -> dict:
+ return {"type": "categorical",
+ "items": [{"name": n, "color": hex_color(palette[i % len(palette)])} for i, n in enumerate(names)]}
+
+
+def continuous_legend(unit, lo, hi, stops) -> dict:
+ vals = np.linspace(lo, hi, len(stops))
+ return {"type": "continuous", "unit": unit,
+ "stops": [[round(float(v), 4), hex_color(s)] for v, s in zip(vals, stops)]}
+
+
+def write_layer(vdir: Path, L: dict) -> dict:
+ """Write one layer's JPEG; returns its layers.json entry."""
+ vdir.mkdir(parents=True, exist_ok=True)
+ Image.fromarray(np.ascontiguousarray(L["rgb"], dtype=np.uint8)).save(vdir / f"{L['id']}.jpg", quality=90)
+ return {"id": L["id"], "name": L["name"], "file": f"{L['id']}.jpg", "legend": L["legend"]}
+
+
+def write_index(vdir: Path, entries: list[dict]) -> None:
+ vdir.mkdir(parents=True, exist_ok=True)
+ (vdir / "layers.json").write_text(json.dumps(entries, indent=1))
+
+
+def write_all(vdir: Path, layers: list[dict]) -> None:
+ write_index(vdir, [write_layer(vdir, L) for L in layers])
diff --git a/mapgen/zones.py b/mapgen/zones.py
new file mode 100644
index 0000000..46386f7
--- /dev/null
+++ b/mapgen/zones.py
@@ -0,0 +1,38 @@
+"""Config O₂ / low-gravity zones: smooth, noise-warped discs in mask units
+(O₂ ×(1 + 0.5 v), gravity ×(1 + 0.7 v)), added to the painted masks in the `sketch` stage. Very gradual: at most
+≈ 1.9 · |effect| / radius per km (≤ 0.23 O₂ points and ≤ 0.014 g per 30 km for the configured zones)."""
+from __future__ import annotations
+
+import numpy as np
+
+from .noise import fbm, name_seed
+from .sphere import gc_dist_km, latlon_to_xyz
+
+WARP = 0.2 # outline wobble: distances stretched or shrunk by up to 20 % (low-frequency noise)
+WARP_FREQ = 1.0
+
+
+def smootherstep(t):
+ t = np.clip(t, 0.0, 1.0)
+ return t * t * t * (t * (6.0 * t - 15.0) + 10.0)
+
+
+def contribution(xyz, zone: dict, seed: int, radius_km: float):
+ """v · (1 − smootherstep(d′ / r)), d′ = d · (1 + WARP · warp): v at the centre, 0 beyond r / (1 − WARP)."""
+ xyz = np.asarray(xyz, dtype=np.float64)
+ d = gc_dist_km(xyz, latlon_to_xyz(*zone["center"]), radius_km)
+ r = float(zone["radius_km"])
+ out = np.zeros(len(xyz))
+ near = d < r / (1.0 - WARP)
+ if near.any():
+ w = np.clip(2.0 * fbm(xyz[near], name_seed(seed, zone["name"]), 3, WARP_FREQ), -1.0, 1.0)
+ out[near] = zone["v"] * (1.0 - smootherstep(d[near] * (1.0 + WARP * w) / r))
+ return out
+
+
+def masks(xyz, zones: list, seed: int, radius_km: float):
+ """(m_o2, m_gravity): the config zones summed (the caller adds the painted masks and clips to [−1, 1])."""
+ m = {"o2": np.zeros(len(xyz)), "gravity": np.zeros(len(xyz))}
+ for z in zones:
+ m[z["field"]] += contribution(xyz, z, seed, radius_km)
+ return m["o2"], m["gravity"]