Files
settled-reach/tooling/planet-gen/planet_simulation.py
T
jpmschweitzerandClaude Opus 4.6 f84275edf3 feat(assets): planet generator pipeline — heightmap + globe from wiki data
Full terrain-to-render pipeline at tooling/planet-gen/:
- body_definition_parser: reads system index.md → body definitions
- planet_simulation: FBM + Voronoi ridges, Whittaker biome classification,
  D8 river routing, physics-driven craters (atmo × tectonics scaling)
- render_heightmap: 4096×2048 cartographic maps with hillshade
- planet_renderer: 512×512 globe with terrain UV mapping, clouds, rings
- biomes.toml: externalized color/classification tables (single source)
- scaffold_bodies: creates per-body index.md with YAML frontmatter
- batch: unattended processing with error handling, resume, determinism check

Supports all body types: temperate, arid, frozen, volcanic, barren,
oceanic, gas giant (banded + ringed), and moons (half-size, grey, cratered).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 15:54:15 +02:00

957 lines
40 KiB
Python

"""
planet_simulation.py
--------------------
Terrain simulation stack for the Settled Reach planet generator.
Consumes a body_definition dict (output of body_definition_parser.py)
and produces a terrain dict consumed by planet_renderer.render_globe().
Output terrain dict:
{
"elevation": float32 (H, W) [0, 1] normalised elevation
"temperature": float32 (H, W) [0, 1] 0=coldest, 1=hottest
"moisture": float32 (H, W) [0, 1] 0=driest, 1=wettest
"biome": int8 (H, W) biome class index
"surface_water": bool (H, W) ocean/lake mask
"hillshade": float32 (H, W) [0, 1] lighting from slope+aspect
"river_grid": bool (H, W) river cell mask
"rivers": list of [(row,col), ...] polylines in grid coords
"sea_level": float elevation threshold
}
Pipeline:
1. Elevation - continent mask + domain-warped FBM + tectonic ridges + erosion
2. Temperature - analytical formula: star + latitude + altitude
3. Moisture - Hadley cells + ocean proximity + rain shadow
4. Hillshade - surface normals from elevation gradient
5. Rivers - downhill carving from moisture-seeded sources
6. Biome - extended Whittaker lookup + modifier stack
Grid: 512 x 256 (longitude x latitude), equirectangular.
Row 0 = north pole, row 255 = south pole.
Col 0 = 180W, col 511 = 180E.
"""
import logging
import math
import numpy as np
from scipy.ndimage import gaussian_filter
from biome_config import (
WHITTAKER_TABLE, CLASS_T_BAND, EXOTIC_CLASSES, CRATER_SCALING,
)
log = logging.getLogger(__name__)
GRID_W = 512
GRID_H = 256
# ---------------------------------------------------------------------------
# Seeded RNG
# ---------------------------------------------------------------------------
def _rng(seed: int, salt: int = 0) -> np.random.Generator:
return np.random.default_rng(seed ^ (salt * 2654435761))
# ---------------------------------------------------------------------------
# Noise primitives
# ---------------------------------------------------------------------------
def _hash2(x: np.ndarray, y: np.ndarray, seed: int) -> np.ndarray:
s = np.int64(seed & 0xFFFF)
h = (x.astype(np.int64) * np.int64(1619) +
y.astype(np.int64) * np.int64(31337) +
s * np.int64(6971)) & np.int64(0xFFFFFFFF)
h = ((h >> 16) ^ h) * np.int64(0x45d9f3b) & np.int64(0xFFFFFFFF)
h = ((h >> 16) ^ h) * np.int64(0x45d9f3b) & np.int64(0xFFFFFFFF)
return (h & np.int64(0xFFFF)).astype(np.float32) / 65535.0
def _vnoise(u, v, freq, seed):
"""Standard 2D value noise — NOT seamless. Use _vnoise_s for longitude axis."""
uf = u * freq; vf = v * freq
x0 = np.floor(uf).astype(np.int32); y0 = np.floor(vf).astype(np.int32)
x1 = x0 + 1; y1 = y0 + 1
tx = uf - x0; ty = vf - y0
tx = tx * tx * (3.0 - 2.0 * tx)
ty = ty * ty * (3.0 - 2.0 * ty)
v00 = _hash2(x0, y0, seed); v10 = _hash2(x1, y0, seed)
v01 = _hash2(x0, y1, seed); v11 = _hash2(x1, y1, seed)
return (v00*(1-tx)*(1-ty) + v10*tx*(1-ty) +
v01*(1-tx)*ty + v11*tx*ty).astype(np.float32)
def _hash3(x, y, z, seed):
"""Hash for 3D integer coords."""
s = np.int64(seed & 0xFFFF)
h = (x.astype(np.int64) * np.int64(1619) +
y.astype(np.int64) * np.int64(31337) +
z.astype(np.int64) * np.int64(49979) +
s * np.int64(6971)) & np.int64(0xFFFFFFFF)
h = ((h >> 16) ^ h) * np.int64(0x45d9f3b) & np.int64(0xFFFFFFFF)
h = ((h >> 16) ^ h) * np.int64(0x45d9f3b) & np.int64(0xFFFFFFFF)
return (h & np.int64(0xFFFF)).astype(np.float32) / 65535.0
def _vnoise_seamless(u, v, freq, seed):
"""
Seamless value noise in the U (longitude) axis only.
Maps u -> (cos(u*2π), sin(u*2π)) before hashing, so the noise
field is periodic in U with period 1 — no seam at the date line.
V (latitude) is not periodic — poles are endpoints, not a loop.
"""
# Project U onto a circle: (cx, cy)
# Divide circle radius by 2π so one full revolution spans the same
# distance as freq units on the flat V axis — corrects aspect ratio.
angle = u * (2.0 * math.pi)
r = freq / (2.0 * math.pi)
cx = np.cos(angle) * r
cy = np.sin(angle) * r
vf = v * freq
# Integer lattice in 3D (cx, cy, vf)
x0 = np.floor(cx).astype(np.int32); x1 = x0 + 1
y0 = np.floor(cy).astype(np.int32); y1 = y0 + 1
z0 = np.floor(vf).astype(np.int32); z1 = z0 + 1
# Smoothstep weights
tx = cx - x0; tx = tx * tx * (3.0 - 2.0 * tx)
ty = cy - y0; ty = ty * ty * (3.0 - 2.0 * ty)
tz = vf - z0; tz = tz * tz * (3.0 - 2.0 * tz)
# Trilinear interpolation over 8 corners
v000 = _hash3(x0, y0, z0, seed); v100 = _hash3(x1, y0, z0, seed)
v010 = _hash3(x0, y1, z0, seed); v110 = _hash3(x1, y1, z0, seed)
v001 = _hash3(x0, y0, z1, seed); v101 = _hash3(x1, y0, z1, seed)
v011 = _hash3(x0, y1, z1, seed); v111 = _hash3(x1, y1, z1, seed)
return (v000*(1-tx)*(1-ty)*(1-tz) + v100*tx*(1-ty)*(1-tz) +
v010*(1-tx)*ty*(1-tz) + v110*tx*ty*(1-tz) +
v001*(1-tx)*(1-ty)*tz + v101*tx*(1-ty)*tz +
v011*(1-tx)*ty*tz + v111*tx*ty*tz).astype(np.float32)
def _fbm(u, v, seed, octaves=6, lacunarity=2.0, gain=0.50, base_freq=2.0):
"""FBM using seamless noise in U — no longitude seam."""
result = np.zeros_like(u, dtype=np.float32)
amp = 1.0; freq = base_freq; total = 0.0
rng = np.random.default_rng(seed)
for _ in range(octaves):
oct_seed = int(rng.integers(0, 0x7FFFFFFF))
result += amp * _vnoise_seamless(u, v, freq, oct_seed)
total += amp
amp *= gain; freq *= lacunarity
return result / (total + 1e-9)
def _domain_warp(u, v, seed, strength=0.35):
"""Domain warp using seamless FBM — preserves no-seam property."""
wu = _fbm(u + 1.7, v + 9.2, seed + 1, octaves=4) * 2.0 - 1.0
wv = _fbm(u + 8.3, v + 2.8, seed + 2, octaves=4) * 2.0 - 1.0
# Only warp u periodically — keep v warp non-periodic (poles stay poles)
return (u + wu * strength) % 1.0, np.clip(v + wv * strength * 0.5, 0.0, 1.0)
# ---------------------------------------------------------------------------
# Coordinate grids
# ---------------------------------------------------------------------------
def _make_grids():
u_1d = np.linspace(0, 1, GRID_W, dtype=np.float32)
v_1d = np.linspace(0, 1, GRID_H, dtype=np.float32)
u, v = np.meshgrid(u_1d, v_1d)
lat_frac = -(v - 0.5) * 2.0 # +1 = north, -1 = south
lon_frac = (u - 0.5) * 2.0
lat_rad = lat_frac * (math.pi / 2.0)
return u, v, lat_frac, lon_frac, lat_rad
# ---------------------------------------------------------------------------
# 1. Elevation
# ---------------------------------------------------------------------------
def _continent_mask(u, v, seed, land_fraction):
def _norm(a):
lo, hi = a.min(), a.max()
return (a - lo) / (hi - lo + 1e-9)
def _contrast(a, strength=3.0):
"""
S-curve contrast: pushes highs toward 1 and lows toward 0
regardless of the field mean. More reliable than power curves
which behave differently depending on the field's distribution.
strength controls steepness — higher = sharper separation.
"""
# Sigmoid centred at 0.5: f(x) = 1/(1+exp(-k*(x-0.5)))
k = strength * 8.0
return 1.0 / (1.0 + np.exp(-k * (a - 0.5)))
# Primary: large continental plates
wu1, wv1 = _domain_warp(u, v, seed, strength=0.45)
primary = _norm(_fbm(wu1, wv1, seed + 10, octaves=5, gain=0.58, base_freq=1.2))
# Secondary: independent medium-scale field.
# S-curve contrast gives reliable highs and lows regardless of seed.
wu2, wv2 = _domain_warp(u, v, seed + 11, strength=0.40)
sec_raw = _norm(_fbm(wu2, wv2, seed + 20, octaves=5, gain=0.55, base_freq=1.8))
secondary = _contrast(sec_raw, strength=2.5)
# Rift: anisotropic thin elongated features
wu3, wv3 = _domain_warp(u, v, seed + 17, strength=0.30)
rift = _norm(_fbm(wu3, wv3 * 0.35, seed + 30, octaves=4, gain=0.52, base_freq=3.5))
# Multiplicative gate: secondary zeroes kill primary → ocean channels
separated = primary * (0.4 + secondary * 0.6)
combined = separated * 0.82 + (rift - 0.5) * 0.18
return _norm(combined).astype(np.float32)
def _tectonic_ridges(u, v, seed, n_plates=8):
rng = _rng(seed, 99)
px = rng.uniform(0, 1, n_plates).astype(np.float32)
py = rng.uniform(0, 1, n_plates).astype(np.float32)
H, W = u.shape
# Domain-warp coords before Voronoi — bends ridge positions into curves
wu1 = _fbm(u * 1.5 + 3.1, v * 1.5 + 7.4, seed + 201, octaves=3,
gain=0.55, base_freq=1.8) * 2.0 - 1.0
wv1 = _fbm(u * 1.5 + 8.6, v * 1.5 + 2.2, seed + 202, octaves=3,
gain=0.55, base_freq=1.8) * 2.0 - 1.0
wu2 = _fbm(u * 4.0 + 1.3, v * 4.0 + 5.7, seed + 203, octaves=2,
gain=0.50, base_freq=3.5) * 2.0 - 1.0
wv2 = _fbm(u * 4.0 + 6.1, v * 4.0 + 0.9, seed + 204, octaves=2,
gain=0.50, base_freq=3.5) * 2.0 - 1.0
uw = (u + wu1 * 0.22 + wu2 * 0.08) % 1.0
vw = np.clip(v + wv1 * 0.18 + wv2 * 0.06, 0.0, 1.0)
dist1 = np.full((H, W), np.inf, dtype=np.float32)
dist2 = np.full((H, W), np.inf, dtype=np.float32)
for i in range(n_plates):
du = np.minimum(np.abs(uw - px[i]), 1.0 - np.abs(uw - px[i]))
dv = np.abs(vw - py[i])
d = np.sqrt(du**2 + dv**2)
mask = d < dist1
dist2 = np.where(mask, dist1, np.minimum(dist2, d))
dist1 = np.where(mask, d, dist1)
# Two ridge widths: broad ranges + sharp collision zones
broad = np.exp(-((dist2 - dist1) / 0.06) ** 2) * 0.5
sharp = np.exp(-((dist2 - dist1) / 0.025) ** 2) * 1.0
ridge_raw = np.clip(broad + sharp, 0, 1)
# Amplitude variation along ridge
ridge_noise = _fbm(u, v, seed + 50, octaves=4, gain=0.55, base_freq=4.0)
# Fracture zones — cross-cutting features (transform faults, rift valleys)
# Anisotropic: stretch u relative to v for elongated cross features
fracture = _fbm(u * 0.4, v, seed + 77, octaves=3, gain=0.6, base_freq=6.0)
fracture = np.clip(fracture - 0.55, 0, 1) * 2.0
return np.clip(ridge_raw * (0.35 + 0.65 * ridge_noise)
+ fracture * 0.20, 0, 1).astype(np.float32)
def _erode(terrain, passes, seed):
result = terrain.copy()
for _ in range(passes):
gy, gx = np.gradient(result)
slope = np.sqrt(gx**2 + gy**2)
smooth = gaussian_filter(result, sigma=1.2)
weight = np.clip(slope * 6.0, 0.0, 1.0)
result = result * (1.0 - weight * 0.35) + smooth * (weight * 0.35)
gy, gx = np.gradient(result)
slope = np.sqrt(gx**2 + gy**2)
flow = gaussian_filter(slope, sigma=3.0)
flow = (flow - flow.min()) / (flow.max() - flow.min() + 1e-9)
result = result - flow * 0.06
return np.clip(result, 0.0, 1.0)
def compute_elevation(body_def, u, v, lat_frac):
seed = body_def["seed"]
planet_class = body_def["planet_class"].replace("_ringed", "")
land_frac = body_def["terrain"]["land_fraction"]
tectonics = body_def["terrain"].get("tectonics", "active")
plate_map = {"extreme": 12, "active": 8, "low": 5, "none": 3}
erosion_map = {"extreme": 1, "active": 3, "low": 4, "none": 2}
n_plates = plate_map.get(tectonics, 8)
erosion_p = erosion_map.get(tectonics, 3)
ocean_pct = (1.0 - land_frac) * 100.0
detail = _fbm(u, v, seed + 300, octaves=5, gain=0.45, base_freq=4.0)
if tectonics == "none":
# No tectonic activity: gentle base terrain, no ridges, no continents.
# Craters dominate on these worlds.
base = _fbm(u, v, seed + 100, octaves=4, gain=0.50, base_freq=1.5)
elev = base * 0.60 + detail * 0.40
else:
# Tectonic worlds: continent mask + ridges scaled by activity level.
cont = _continent_mask(u, v, seed, land_frac)
ridges = _tectonic_ridges(u, v, seed, n_plates=n_plates)
sea_level_est = float(np.percentile(cont, ocean_pct))
land_mask = cont >= sea_level_est
# Ridge prominence scales with tectonic activity
ridge_weight = {"low": 0.12, "active": 0.25, "extreme": 0.38}
rw = ridge_weight.get(tectonics, 0.25)
elev = (cont * (0.80 - rw)
+ ridges * rw * land_mask
+ detail * 0.20)
# Craters happen everywhere. Atmosphere controls how many impactors
# survive entry; tectonics controls how many craters get resurfaced.
# Both reduce density, neither toggles craters off entirely.
crater_factor = (CRATER_SCALING["atmosphere"].get(body_def["physical"]["atmosphere"], 0.25)
* CRATER_SCALING["tectonics"].get(tectonics, 0.3))
if crater_factor > 0.02:
rng = _rng(seed, 77)
base_count = CRATER_SCALING["base_count"]
n_craters = max(5, int(base_count * crater_factor))
cy_c = rng.uniform(0, GRID_H, n_craters).astype(np.float32)
cx_c = rng.uniform(0, GRID_W, n_craters).astype(np.float32)
# Power-law: most craters are small (2-5 cells), a few are large (15-30)
raw_sizes = rng.power(0.4, n_craters) # skewed toward 0
sizes = (2 + raw_sizes * 28).astype(np.float32)
# Depth scales with crater factor — eroded worlds have shallower craters
depth_scale = 0.5 + 0.5 * crater_factor
depths = ((0.05 + raw_sizes * 0.20) * depth_scale).astype(np.float32)
rows = np.arange(GRID_H, dtype=np.float32)
cols = np.arange(GRID_W, dtype=np.float32)
rr, cc = np.meshgrid(rows, cols, indexing='ij')
# Latitude correction: scale longitude distance by cos(lat) so
# craters are circular on the sphere, not stretched at the poles.
lat_rad = (0.5 - rr / GRID_H) * math.pi # +pi/2 at north, -pi/2 at south
cos_lat = np.cos(lat_rad)
cos_lat = np.clip(cos_lat, 0.1, 1.0) # avoid division issues at poles
craters = np.zeros_like(elev)
for i in range(n_craters):
dy = rr - cy_c[i]
dx = cc - cx_c[i]
# Wrap longitude for craters near the date line
dx = np.minimum(np.abs(dx), GRID_W - np.abs(dx))
# Scale dx by cos(lat) at the crater center
center_lat = (0.5 - cy_c[i] / GRID_H) * math.pi
dx_scaled = dx / max(math.cos(center_lat), 0.1)
d = np.sqrt(dy**2 + dx_scaled**2)
r = sizes[i]
dep = depths[i]
# Crater profile: flat floor inside 0.6r, raised rim at 0.9-1.1r,
# smooth falloff outside. More realistic than gaussian dimple.
floor = np.clip(1.0 - d / (r * 0.6), 0, 1)
rim = np.exp(-((d - r) / (r * 0.25))**2)
craters -= dep * floor * 0.8 # excavate floor
craters += dep * rim * 0.3 # raise rim
elev = elev + craters
elev = np.clip(elev, 0.0, None) # floor at 0
elif planet_class == "frozen":
elev = gaussian_filter(elev, sigma=1.5).astype(np.float32)
elif planet_class == "volcanic":
erosion_p = max(1, erosion_p - 1)
elev = _erode(elev, passes=erosion_p, seed=seed)
lo, hi = elev.min(), elev.max()
elev = (elev - lo) / (hi - lo + 1e-9)
sea_level = float(np.percentile(elev, ocean_pct))
# Polar ice — smooth land elevation toward a low plateau at high latitudes.
# Only applies when there's an atmosphere to deliver precipitation/ice.
# Airless bodies have no polar caps — cold rock stays rock.
hydro = body_def.get("environment", {}).get("hydrosphere", "ocean")
atmo = body_def["physical"]["atmosphere"]
has_polar_ice = atmo not in ("none",) and hydro not in ("none", "subsurface")
if has_polar_ice:
ice_lat = body_def["terrain"].get("polar_ice_lat", 0.80)
lat_abs = np.abs(lat_frac)
ice_blend = np.clip((lat_abs - ice_lat) / (1.0 - ice_lat + 0.01), 0, 1)
if planet_class == "frozen":
ice_blend = np.clip(ice_blend * 2.0, 0, 1)
land_mask = elev >= sea_level
ice_target = sea_level + 0.05
elev = np.where(
land_mask,
elev * (1.0 - ice_blend * 0.6) + ice_target * (ice_blend * 0.6),
elev)
elev = np.clip(elev, 0.0, 1.0).astype(np.float32)
sea_level = float(np.percentile(elev, ocean_pct))
# Dry worlds (no/subsurface hydrosphere): low elevation is dry basin, not ocean.
hydro = body_def.get("environment", {}).get("hydrosphere", "ocean")
if hydro in ("none", "subsurface"):
surf_water = np.zeros_like(elev, dtype=bool)
else:
surf_water = elev < sea_level
return elev, sea_level, surf_water
# ---------------------------------------------------------------------------
# 2. Temperature
# ---------------------------------------------------------------------------
# Stellar luminosity relative to Sol (approximate midpoint per spectral type)
STAR_LUMINOSITY = {
"O": 100000.0, "B": 1000.0, "A": 10.0,
"F": 2.5, "G": 1.0, "K": 0.4, "M": 0.04,
}
# CLASS_T_BAND loaded from biomes.toml via biome_config
def compute_temperature(body_def, elevation, sea_level, lat_frac):
star_type = body_def["star"]["type"]
distance_au = body_def["orbit"]["distance_au"]
axial_tilt = body_def["orbit"]["axial_tilt_deg"]
atmo = body_def["physical"]["atmosphere"]
planet_class = body_def["planet_class"].replace("_ringed", "")
geothermal = body_def.get("environment", {}).get("geothermal_flux", "low")
# Equilibrium temperature — descriptor-anchored.
#
# We compute the raw stellar physics (Stefan-Boltzmann) to get a
# physically grounded value, then clamp it to the temperature band
# appropriate for the planet_class. This ensures the wiki's descriptors
# (temperate, frozen, arid…) are always honoured even when orbital
# parameters were set with "close enough" precision.
#
# Within the clamped band, the raw value still drives relative warmth:
# a close-in temperate world sits at the warm end of the temperate band,
# a far-out one at the cool end. The fiction wins; physics sets the gradient.
lum = body_def.get("star", {}).get("luminosity_solar",
STAR_LUMINOSITY.get(star_type, 1.0))
t_raw = 278.5 * (lum ** 0.25) / math.sqrt(max(distance_au, 0.01))
greenhouse = {"none": 0, "thin": 8, "standard": 33, "thick": 80}
t_raw += greenhouse.get(atmo, 0)
temperature_clamped = False
temperature_raw_K = float(t_raw)
if planet_class in CLASS_T_BAND:
t_lo, t_hi = CLASS_T_BAND[planet_class]
t_base = float(np.clip(t_raw, t_lo, t_hi))
if t_raw < t_lo or t_raw > t_hi:
temperature_clamped = True
log.debug(f" T_raw={t_raw:.0f}K clamped to [{t_lo},{t_hi}] "
f"for {planet_class} ({body_def.get('id','')})")
else:
t_base = t_raw
tilt_factor = 1.0 - (axial_tilt / 90.0) * 0.5
# Atmosphere controls heat redistribution — thicker atmo = smaller
# equator-pole gradient. Thin/no atmo = extreme day/night but we
# still want the planet class to read correctly at the poles.
atmo_gradient_scale = {"none": 0.6, "thin": 0.7, "standard": 1.0, "thick": 1.2}
lat_gradient = 60.0 * tilt_factor * atmo_gradient_scale.get(atmo, 1.0)
t_lat = t_base - lat_gradient * np.abs(lat_frac)
max_relief_km = body_def.get("terrain", {}).get("max_elevation_km", 10.0)
elev_land = np.where(elevation >= sea_level,
(elevation - sea_level) / (1.0 - sea_level + 1e-9), 0.0)
elev_km = elev_land * max_relief_km
lapse = 6.5 if atmo != "none" else 2.0
t_final = t_lat - lapse * elev_km
class_offset = {"frozen": -30, "volcanic": 20, "arid": 10}
t_final += class_offset.get(planet_class, 0)
geo_boost = {"low": 0, "moderate": 5, "high": 15, "extreme": 35}
t_final += geo_boost.get(geothermal, 0)
# Soft floor: prevent planet class from being contradicted at the poles.
# An arid world shouldn't have ice caps; a volcanic world shouldn't freeze.
# Clamp the minimum temperature to the class band's lower bound.
if planet_class in CLASS_T_BAND:
t_floor = CLASS_T_BAND[planet_class][0]
t_final = np.maximum(t_final, t_floor)
# Return absolute Kelvin grid plus audit metadata.
# Biome lookup needs absolute values; renderer normalises for display.
return t_final.astype(np.float32), temperature_clamped, temperature_raw_K
# ---------------------------------------------------------------------------
# 3. Moisture
# ---------------------------------------------------------------------------
def compute_moisture(body_def, elevation, sea_level, temperature,
lat_frac, lon_frac):
# Normalise temperature locally for moisture computation
t_norm = np.clip((temperature - temperature.min()) /
(temperature.max() - temperature.min() + 1e-9), 0, 1)
atmo = body_def["physical"]["atmosphere"]
planet_class = body_def["planet_class"].replace("_ringed", "")
hydro = body_def.get("environment", {}).get("hydrosphere", "none")
if atmo == "none":
return np.zeros((GRID_H, GRID_W), dtype=np.float32)
lat_abs = np.abs(lat_frac)
# Hadley cell bands
itcz = np.clip(1.0 - (lat_abs / 0.33), 0, 1)
subtr = np.clip(1.0 - np.abs(lat_abs - 0.50) / 0.17, 0, 1)
polar = np.clip((lat_abs - 0.67) / 0.33, 0, 1)
hadley = np.clip(itcz * 0.85 + subtr * 0.10 + polar * 0.40, 0, 1)
# Ocean proximity
surf_water = elevation < sea_level
if surf_water.any():
from scipy.ndimage import distance_transform_edt
dist = distance_transform_edt(~surf_water).astype(np.float32)
ocean_prox = 1.0 - np.clip(dist / (dist.max() * 0.5 + 1e-9), 0, 1)
else:
ocean_prox = np.zeros((GRID_H, GRID_W), dtype=np.float32)
# Rain shadow — westerly winds: windward (west face) is wet
shift = max(1, GRID_W // 80)
elev_above = np.clip(elevation - sea_level, 0, None)
elev_sh = np.clip(np.roll(elevation, shift, axis=1) - sea_level, 0, None)
shadow_raw = np.clip(elev_sh - elev_above * 0.5, 0, None)
shadow_raw = shadow_raw / (shadow_raw.max() + 1e-9)
rain_shadow = 1.0 - shadow_raw * 0.70
moisture = (hadley * 0.40
+ ocean_prox * 0.45
+ t_norm * 0.15) * rain_shadow
class_scale = {
"arid": 0.25, "oceanic": 1.30, "forest": 1.30,
"frozen": 0.55, "volcanic": 0.40, "barren": 0.05,
}
moisture *= class_scale.get(planet_class, 1.0)
hydro_scale = {
"ocean": 1.2, "liquid_water": 1.2,
"subsurface": 0.1, "none": 0.05,
}
moisture *= hydro_scale.get(hydro, 1.0)
moisture = gaussian_filter(moisture.astype(np.float32), sigma=2.0)
# Only normalize if the raw range is substantial — otherwise the
# normalization re-inflates near-zero moisture on dry worlds back to [0,1].
m_min, m_max = moisture.min(), moisture.max()
if m_max > 0.05:
moisture = ((moisture - m_min) / (m_max - m_min + 1e-9)).astype(np.float32)
else:
# Effectively dry — clamp to near-zero
moisture = np.clip(moisture / 0.05, 0, 1).astype(np.float32)
return moisture
# ---------------------------------------------------------------------------
# 4. Hillshade
# ---------------------------------------------------------------------------
def compute_hillshade(elevation,
sun_azimuth_deg=315.0,
sun_altitude_deg=45.0):
scale = GRID_W / 8.0
gy, gx = np.gradient(elevation * scale)
mag = np.sqrt(gx**2 + gy**2 + 1.0)
nx = -gx / mag; ny = -gy / mag; nz = 1.0 / mag
az = math.radians(sun_azimuth_deg)
alt = math.radians(sun_altitude_deg)
lx = math.cos(alt) * math.cos(az)
ly = math.cos(alt) * math.sin(az)
lz = math.sin(alt)
diffuse = np.clip(nx * lx + ny * ly + nz * lz, 0.0, 1.0)
return (0.25 + 0.75 * diffuse).astype(np.float32)
# ---------------------------------------------------------------------------
# 5. Rivers
# ---------------------------------------------------------------------------
def compute_rivers(body_def, elevation, sea_level, moisture,
max_rivers=12):
atmo = body_def["physical"]["atmosphere"]
planet_class = body_def["planet_class"].replace("_ringed", "")
hydro = body_def.get("environment", {}).get("hydrosphere", "none")
if atmo == "none" or hydro in ("none", "subsurface", "ice"):
return []
river_cap = {"barren": 2, "volcanic": 3, "arid": 3, "frozen": 2}
max_rivers = river_cap.get(planet_class, max_rivers)
H, W = elevation.shape
land_mask = elevation >= sea_level
seed = body_def["seed"]
rng = _rng(seed, 500)
from scipy.ndimage import maximum_filter
local_max = (elevation == maximum_filter(elevation, size=8)) & land_mask
moist_ok = moisture > 0.35
candidates = np.argwhere(local_max & moist_ok)
if len(candidates) == 0:
candidates = np.argwhere(land_mask)
np.random.default_rng(seed).shuffle(candidates)
sources = candidates[:min(max_rivers, len(candidates))]
D8 = [(-1,-1),(-1,0),(-1,1),(0,-1),(0,1),(1,-1),(1,0),(1,1)]
rivers = []
for src in sources:
r, c = int(src[0]), int(src[1])
path = [(r, c)]
visited = {(r, c)}
for _ in range(GRID_W * 2):
if elevation[r, c] < sea_level:
break
best_drop = 0.0; best_nr = -1; best_nc = -1
for dr, dc in D8:
nr = r + dr; nc = (c + dc) % W
if nr < 0 or nr >= H or (nr, nc) in visited:
continue
drop = elevation[r, c] - elevation[nr, nc]
drop += float(rng.uniform(-0.005, 0.005))
if drop > best_drop:
best_drop = drop; best_nr = nr; best_nc = nc
if best_nr < 0:
break
r, c = best_nr, best_nc
visited.add((r, c))
path.append((r, c))
if len(path) > 5:
rivers.append(path)
return rivers
def _rivers_to_grid(rivers, H, W):
grid = np.zeros((H, W), dtype=bool)
for path in rivers:
for r, c in path:
if 0 <= r < H and 0 <= c < W:
grid[r, c] = True
return grid
# ---------------------------------------------------------------------------
# 6. Biome
# ---------------------------------------------------------------------------
# WHITTAKER_TABLE, EXOTIC_CLASSES loaded from biomes.toml via biome_config
def compute_biome(body_def, elevation, sea_level, surface_water,
temperature, moisture):
H, W = elevation.shape
biome = np.zeros((H, W), dtype=np.int8)
land = ~surface_water
atmo = body_def["physical"]["atmosphere"]
# --- Atmosphere gate ---
# Worlds with no or thin atmosphere can't support vegetation.
# Skip the Whittaker table entirely — classify by elevation and
# temperature only, using rock/dust/ice classes.
if atmo in ("none", "thin"):
# Dry terrain classes: 27=dust plain, 28=rocky highland,
# 29=warm dust, 30=cold rock. No vegetation possible.
# No ice on airless worlds — cold rock stays rock.
hydro = body_def.get("environment", {}).get("hydrosphere", "none")
has_ice_source = hydro not in ("none", "subsurface") or atmo == "thin"
elev_norm = np.where(land,
(elevation - sea_level) / (1.0 - sea_level + 1e-9),
0.0)
cf = np.full(land.sum(), 28, dtype=np.int8) # default: rocky highland
tf = temperature[land].ravel()
en = elev_norm[land].ravel()
# Moon vs planet: moons use grey lunar palette, planets use warm rock
is_lunar = body_def.get("body_type") == "moon"
if is_lunar:
# Lunar classes: 31=highland, 32=mare (dark basin), 33=midland
cf[:] = 33 # default: midland grey
cf[en > 0.50] = 31 # highland
cf[en < 0.20] = 32 # mare (dark basin floor)
if has_ice_source:
cf[tf < 200] = 17 # ice (only if water source)
else:
# Temperature-based classification using dry terrain classes
if has_ice_source:
cf[tf < 200] = 17 # ice/snow (only if water source)
else:
cf[tf < 200] = 30 # cold rock (no water = no ice)
cf[(tf >= 200) & (tf < 260)] = 30 # cold rock
cf[(tf >= 260) & (tf < 310)] = 28 # rocky highland
cf[(tf >= 310) & (tf < 340)] = 29 # warm dust
cf[tf >= 340] = 15 # hot desert (scorched)
# Elevation variation
if has_ice_source:
cf[(en > 0.70) & (tf < 273)] = 17 # high + cold = ice cap
cf[(en < 0.20) & (tf >= 260)] = 27 # low elevation = dust plain
biome[land] = cf
else:
# --- Standard Whittaker lookup for breathable/toxic atmospheres ---
tf = temperature[land].ravel()
mf = moisture[land].ravel()
cf = np.full(tf.shape, 17, dtype=np.int8) # default: ice
# Temperature fed to biome is absolute Kelvin — compare directly
for (tlo, thi, mlo, mhi, cls) in WHITTAKER_TABLE:
mask = (tf >= tlo) & (tf <= thi) & (mf >= mlo) & (mf <= mhi)
cf[mask] = cls
biome[land] = cf
# Ocean depth bands
if surface_water.any():
depth = np.clip((sea_level - elevation) / (sea_level + 1e-9), 0, 1)
biome[surface_water & (depth < 0.15)] = 2
biome[surface_water & (depth >= 0.15) & (depth < 0.50)] = 1
biome[surface_water & (depth >= 0.50)] = 0
# Frozen ocean — override ocean biome with ice shelf (class 26).
# Distinct from land ice (17) — slightly different appearance,
# blue tint suggests ocean beneath.
# Add noise to the freeze threshold so the boundary isn't a straight
# latitude line — ice edges are irregular in reality.
seed = body_def["seed"]
u_grid, v_grid, _, _, _ = _make_grids()
ice_noise = _fbm(u_grid, v_grid, seed + 900, octaves=4,
gain=0.5, base_freq=3.0) * 2.0 - 1.0
freeze_threshold = 271.0 + ice_noise * 8.0 # ±8K variation
frozen_ocean = surface_water & (temperature < freeze_threshold)
biome[frozen_ocean] = 26
# Very cold override — only on worlds with atmosphere (ice needs deposition)
if atmo not in ("none",):
biome[(temperature < 243.0) & land] = 17 # below -30C → ice
# Elevation overrides — mountain rock and permanent snow.
# Only apply snow on worlds with atmosphere (ice needs deposition).
elev_norm = np.where(land,
(elevation - sea_level) / (1.0 - sea_level + 1e-9),
0.0)
hydro_here = body_def.get("environment", {}).get("hydrosphere", "none")
has_ice_deposition = atmo not in ("none",) and hydro_here not in ("none", "subsurface")
if has_ice_deposition:
biome[land & (elev_norm > 0.85)] = 17
biome[land & (elev_norm > 0.65) & (temperature < 0.35)] = 18
# ── Modifier stack ─────────────────────────────────────────────────────
env = body_def.get("environment", {})
geothermal = env.get("geothermal_flux", "low")
chemosyn = env.get("chemosynthetic", False)
uv_index = env.get("uv_index", "moderate")
substrate = env.get("substrate", "silicate")
atmo = body_def["physical"]["atmosphere"]
planet_class = body_def["planet_class"].replace("_ringed", "")
# Geothermal: volcanic worlds get lava/ash at high elevations
if geothermal in ("extreme", "high") and planet_class == "volcanic":
biome[land & (elev_norm > 0.75)] = EXOTIC_CLASSES["lava_field"]
biome[land & (elev_norm > 0.45) & (elev_norm <= 0.75)] = EXOTIC_CLASSES["ash_field"]
# Thermophilic fields near heat vents on any high-geothermal world
if geothermal in ("extreme", "high") and not chemosyn:
hot = (temperature > 303.0) & land & (elev_norm < 0.45)
biome[hot] = EXOTIC_CLASSES["thermophilic_field"]
# Chemosynthetic worlds (Europa-type): cold surface, geothermal warmth
if chemosyn:
geo_warm = (temperature > 263.0) & (temperature < 293.0) & land
biome[geo_warm] = EXOTIC_CLASSES["chemosynthetic_mat"]
# UV radiation: cryptobiotic crust on exposed terrain with thin/no atmo
if uv_index in ("extreme", "high") and atmo in ("none", "thin"):
exposed = (land & (elev_norm > 0.15) & (elev_norm < 0.65)
& (moisture < 0.30)
& (biome != 17) & (biome != 18) & (biome != 19))
biome[exposed] = EXOTIC_CLASSES["cryptobiotic_crust"]
# Sulfuric substrate: scrub on volcanic mid-elevations
if substrate == "sulfuric":
scrub = land & (elev_norm > 0.25) & (elev_norm < 0.65) & (temperature > 0.35)
biome[scrub & (biome == 18)] = EXOTIC_CLASSES["sulfuric_scrub"]
# ── Anomaly scatter ─────────────────────────────────────────────────
# Sparse micro-features that break biome uniformity and tell stories.
# A high-frequency noise field selects ~2-5% of cells for anomaly
# replacement. The anomaly type depends on the surrounding biome context.
if atmo not in ("none",):
seed = body_def["seed"]
u_grid, v_grid, _, _, _ = _make_grids()
scatter_noise = _fbm(u_grid, v_grid, seed + 800, octaves=3,
gain=0.6, base_freq=12.0)
# High threshold = sparse features (~3% of land)
scatter_mask = (scatter_noise > 0.72) & land
if scatter_mask.any():
b_local = biome[scatter_mask]
t_local = temperature[scatter_mask]
m_local = moisture[scatter_mask]
e_local = elev_norm[scatter_mask]
new_b = b_local.copy()
# Temperate/forest → volcanic vent (lava at high elevation)
veg_mask = np.isin(b_local, [5, 6, 7, 8, 9, 10, 11])
new_b[veg_mask & (e_local > 0.50)] = 19 # lava field
new_b[veg_mask & (e_local > 0.35) & (e_local <= 0.50)] = 25 # ash
# Desert/dry → oasis with vegetation ring (only if water exists)
hydro = body_def.get("environment", {}).get("hydrosphere", "none")
has_water = hydro not in ("none", "subsurface")
dry_mask = np.isin(b_local, [13, 14, 15, 27, 28, 29])
if has_water:
# Very rare lake in desert lowlands
new_b[dry_mask & (e_local < 0.10) & (m_local > 0.20)] = 2 # shallow water
# Vegetation around moisture (oasis fringe — works even without
# standing water, represents subsurface moisture reaching roots)
new_b[dry_mask & (m_local > 0.15) & (e_local >= 0.10)] = 7 # savanna
# Frozen → geothermal hotspot with pioneer vegetation
cold_mask = np.isin(b_local, [16, 17])
new_b[cold_mask & (t_local > 260)] = 12 # shrubland (hardy plants)
# Volcanic → cooling zone with pioneer life
lava_mask = np.isin(b_local, [19, 25])
new_b[lava_mask & (t_local < 310) & (m_local > 0.30)] = 24 # lithic pioneer
biome[scatter_mask] = new_b
# Vegetation ring around oasis lakes: dilate water cells from the
# scatter pass and assign graduated vegetation to the ring.
# water → coast vegetation → savanna/shrub → original biome
oasis_water = (biome == 2) & land # scattered lake cells on land
if oasis_water.any():
from scipy.ndimage import binary_dilation
ring1 = binary_dilation(oasis_water, iterations=2) & ~oasis_water & land
ring2 = binary_dilation(oasis_water, iterations=4) & ~oasis_water & ~ring1 & land
# Inner ring: lush vegetation (coast/lowland green)
biome[ring1] = 4 # lowland
# Outer ring: transitional (savanna/shrub)
biome[ring2] = 12 # shrubland
return biome
# ---------------------------------------------------------------------------
# Top-level simulate()
# ---------------------------------------------------------------------------
def simulate(body_def: dict) -> dict:
"""
Run the full simulation stack for one body.
Parameters
----------
body_def : dict — from body_definition_parser.parse_system()
Returns
-------
dict terrain dict consumed by planet_renderer.render_globe()
Empty dict for gas giants (renderer handles those procedurally).
"""
planet_class = body_def.get("planet_class", "barren").replace("_ringed", "")
if planet_class == "gas_giant":
return {}
u, v, lat_frac, lon_frac, lat_rad = _make_grids()
elevation, sea_level, surface_water = compute_elevation(
body_def, u, v, lat_frac)
temperature, temp_clamped, temp_raw_K = compute_temperature(
body_def, elevation, sea_level, lat_frac)
moisture = compute_moisture(
body_def, elevation, sea_level, temperature, lat_frac, lon_frac)
hillshade = compute_hillshade(elevation)
rivers = compute_rivers(body_def, elevation, sea_level, moisture)
river_grid = _rivers_to_grid(rivers, GRID_H, GRID_W)
biome = compute_biome(
body_def, elevation, sea_level, surface_water, temperature, moisture)
# Normalise temperature to [0,1] for renderer display — biome already computed
t_min, t_max = temperature.min(), temperature.max()
temperature_norm = ((temperature - t_min) / (t_max - t_min + 1e-9)).astype(np.float32)
return {
"elevation": elevation,
"temperature": temperature_norm, # normalised [0,1] for renderer
"moisture": moisture,
"biome": biome,
"surface_water": surface_water,
"hillshade": hillshade,
"river_grid": river_grid,
"rivers": rivers,
"sea_level": sea_level,
"_grid_w": GRID_W,
"_grid_h": GRID_H,
# Audit trail
"temperature_clamped": temp_clamped,
"temperature_raw_K": round(temp_raw_K, 1),
"temperature_band_K": list(CLASS_T_BAND.get(
body_def.get("planet_class","").replace("_ringed",""), [None,None])),
}
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import sys, json, time, os
from PIL import Image
if len(sys.argv) < 2:
print("Usage: python3 planet_simulation.py body_def.json [--save-grids]")
sys.exit(1)
with open(sys.argv[1]) as f:
bd = json.load(f)
save_grids = "--save-grids" in sys.argv
print(f"Simulating: {bd['id']} ({bd['planet_class']})")
t0 = time.time()
terrain = simulate(bd)
if not terrain:
print("Gas giant — no terrain simulation.")
sys.exit(0)
dt = time.time() - t0
print(f"Done in {dt:.1f}s")
print(f" sea_level: {terrain['sea_level']:.3f}")
print(f" land cells: {(~terrain['surface_water']).sum()}")
print(f" rivers: {len(terrain['rivers'])} polylines")
ids, counts = np.unique(terrain['biome'], return_counts=True)
print(f" biomes: {list(zip(ids.tolist(), counts.tolist()))}")
if save_grids:
out = f"/tmp/{bd['id']}_grids"
os.makedirs(out, exist_ok=True)
for name in ("elevation", "temperature", "moisture", "hillshade"):
arr = terrain[name]
Image.fromarray((arr * 255).astype("uint8"), "L").save(
f"{out}/{name}.png")
print(f"Grids saved → {out}/")