Files
settled-reach/tooling/domains/atlas/planet/planet_renderer.py
T
jpmschweitzerandClaude Opus 5.5 668772075c refactor(tooling): T-1288 — planet-gen becomes reach atlas planet
The 30-file tree moves under atlas as its third rung (D-243), ten verbs
fronting it. Each verb restates its module's options so `--help` describes
something; tooling/test_planet_router.py hands every declared option to the
module's own argparse and fails on drift, and now runs in make test-tooling.

The 2026-09-02 half of this move had converted the top-level imports and the
repo roots. Finishing it found what the half-move left:

- Lazy in-function imports, and all of sol_data/, still named siblings bare.
  They resolved only through sys.path.insert hacks, so under reach the first
  globe render in generate, batch or sol-import would have raised
  ModuleNotFoundError. Qualified; the hacks are gone.
- 247 print() calls and a stdout progress writer that fired once per 8 KB
  block. Report verbs (audit, quality) write through console.out, progress
  through console.event, and download progress is throttled to 10% steps
  so a job log is not tens of thousands of lines.
- Every error exit raises ReachError with a fix.

Two checks that could not fail:

- batch --verify-determinism printed a warning and exited 0 on a mismatch.
- import-provinces exited 0 with errors > 0.

Both now raise. The 271-body bake is only safe to re-run because the first
one holds.

sol-import --body is action="append" in the module but the router took one
value, so --body GJ0d --body GJ0e kept one. Now repeatable, and _flags repeats
list options.

test_conformance walked one level, so a nested group was reported as a verb
missing @command and its ten verbs were never checked. It recurses now;
proven by stripping @command from `planet quality` and watching it fail.

Stray PNGs from the 2026-09-03 runaway router-test run are parked in
.cache/t1288-stray-pngs/, not committed. Their reliefmaps differ from HEAD
while the heightmap regenerated byte-identical — filed as T-1291.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 16:08:02 +02:00

977 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
planet_renderer.py
------------------
Renders a 2048×2048 planet globe PNG from a body definition dict.
Supported planet_class values:
temperate, oceanic, forest — terrestrial, biome-colored surface
arid, martian — dry terrestrial, dust haze
frozen — ice world, cold-tinted
barren — rocky, no atmosphere
volcanic — dark rock, lava highlight pass
moon — barren + crater density from 'age'
gas_giant — band renderer, no UV wrap
gas_giant_ringed — gas_giant + ring plane composite
Lighting model (terrestrial):
diffuse — Lambert with sharpened terminator
specular — Phong, ocean cells only (masked by surface_water grid)
terminator — warm scatter band at dot(N,L) ≈ 0
rim glow — atmosphere color at grazing angle, lit + dark side
night side — faint ambient scatter, no city lights
clouds — moisture-driven opacity, rendered above surface
Outputs:
PIL Image (RGBA, 2048×2048) — caller saves as PNG
Usage:
from tooling.domains.atlas.planet.planet_renderer import render_globe
img = render_globe(body_def, terrain=None)
img.save("myplanet.png")
# With terrain data:
img = render_globe(body_def, terrain={
"elevation": np.ndarray (H, W) float32 [0,1],
"temperature": np.ndarray (H, W) float32 [0,1],
"moisture": np.ndarray (H, W) float32 [0,1],
"biome": np.ndarray (H, W) int8 [0..N],
"surface_water":np.ndarray (H, W) bool,
})
"""
import math
import numpy as np
from PIL import Image
from tooling.domains.atlas.planet.biome_config import (
BIOME_PALETTE as _BIOME_PALETTE_CFG,
STAR_TINTS as _STAR_TINTS_CFG,
ATMO_COLORS as _ATMO_COLORS_CFG,
GAS_PALETTES as _GAS_PALETTES_CFG,
MAX_BIOME_ID,
)
from tooling.core import console
# ---------------------------------------------------------------------------
# Output resolution
# ---------------------------------------------------------------------------
GLOBE_SIZE = 2048
SPHERE_R = 0.90 # sphere radius in [-1,1] NDC — leaves margin for ring/glow
# ---------------------------------------------------------------------------
# Star color temperature → RGB tint for lighting
# ---------------------------------------------------------------------------
# Star tints loaded from biomes.toml
STAR_TINTS = _STAR_TINTS_CFG
# ---------------------------------------------------------------------------
# Biome palette (index matches Whittaker classification order)
# Colours are float RGB [0,1]
# ---------------------------------------------------------------------------
BIOME_COLORS = np.array([
[0.12, 0.20, 0.38], # 0 ocean deep
[0.16, 0.30, 0.52], # 1 ocean mid
[0.22, 0.42, 0.58], # 2 ocean shallow
[0.50, 0.62, 0.45], # 3 coast / beach
[0.38, 0.52, 0.30], # 4 subtropical dry forest
[0.25, 0.48, 0.22], # 5 tropical rainforest
[0.42, 0.56, 0.28], # 6 tropical seasonal forest
[0.55, 0.60, 0.32], # 7 savanna / grassland
[0.62, 0.58, 0.38], # 8 temperate grassland
[0.30, 0.50, 0.28], # 9 temperate deciduous forest
[0.22, 0.40, 0.25], # 10 temperate rainforest
[0.20, 0.35, 0.22], # 11 boreal / taiga
[0.72, 0.68, 0.58], # 12 shrubland / chaparral
[0.78, 0.70, 0.50], # 13 temperate desert
[0.82, 0.72, 0.52], # 14 subtropical desert
[0.85, 0.78, 0.62], # 15 hot desert
[0.88, 0.88, 0.92], # 16 tundra
[0.92, 0.94, 0.97], # 17 ice / snow
[0.55, 0.50, 0.45], # 18 mountain rock
[0.38, 0.32, 0.28], # 19 volcanic / lava field
], dtype=np.float32)
# Photographic biome colors loaded from biomes.toml via biome_config.
_EXTENDED_BIOME_COLORS = np.zeros((MAX_BIOME_ID + 1, 3), dtype=np.float32)
for _cid, _val in _BIOME_PALETTE_CFG.items():
_EXTENDED_BIOME_COLORS[_cid] = np.array(_val["photographic"], dtype=np.float32) / 255.0
del _cid, _val
# Gas giant palettes and atmosphere colors loaded from biomes.toml
GAS_PALETTES = _GAS_PALETTES_CFG
ATMO_COLORS = _ATMO_COLORS_CFG
# ---------------------------------------------------------------------------
# Noise helpers — pure numpy, no external deps
# ---------------------------------------------------------------------------
def _hash2(x: np.ndarray, y: np.ndarray, seed: int) -> np.ndarray:
"""Deterministic pseudo-random float in [0,1] from integer x,y coords."""
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 _value_noise_octave(u, v, freq, seed):
"""Single octave value noise via bilinear grid interpolation. No sine waves."""
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) # smoothstep
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 fbm(u: np.ndarray, v: np.ndarray, seed: int,
octaves: int = 7, lacunarity: float = 2.0,
gain: float = 0.50) -> np.ndarray:
"""FBM using value noise (bilinear grid). Returns [0,1] float32. No hatching."""
result = np.zeros_like(u, dtype=np.float32)
amplitude = 1.0; frequency = 2.0; total = 0.0
rng = np.random.default_rng(seed)
for i in range(octaves):
oct_seed = int(rng.integers(0, 0x7FFFFFFF))
result += amplitude * _value_noise_octave(u, v, frequency, oct_seed)
total += amplitude
amplitude *= gain; frequency *= lacunarity
return result / (total + 1e-9)
# ---------------------------------------------------------------------------
# Ray-sphere intersection — vectorised over full image
# ---------------------------------------------------------------------------
def _raytrace(size: int, r: float = 1.0, oblateness: float = 0.0):
"""
Camera at (0, 0, 3) looking at origin.
oblateness flattens the sphere along Y (gas giants).
Returns: hit(bool), nx, ny, nz, u, v — all (size, size) float32
"""
lin = np.linspace(-1.0, 1.0, size, dtype=np.float32)
px, py = np.meshgrid(lin, -lin) # y flipped: top = +y
oz = 3.0
rdx = px.copy()
rdy = py.copy()
rdz = np.full((size, size), -oz, dtype=np.float32)
mag = np.sqrt(rdx**2 + rdy**2 + rdz**2)
rdx /= mag; rdy /= mag; rdz /= mag
# Scale Y for oblate spheroid
rdy_s = rdy / (1.0 - oblateness + 1e-9)
b = 2.0 * oz * rdz
c = oz**2 - r**2
disc = b**2 - 4.0 * c
hit = disc >= 0.0
safe = np.maximum(disc, 0.0)
t = np.where(hit, (-b - np.sqrt(safe)) / 2.0, np.inf)
hx = rdx * t
hy = rdy * t
hz = oz + rdz * t
# Surface normal — account for oblate scaling
nx = hx
ny = hy / (1.0 - oblateness + 1e-9)**2
nz = hz
nm = np.where(hit, np.sqrt(nx**2 + ny**2 + nz**2), 1.0)
nx /= nm; ny /= nm; nz /= nm
# UV from undistorted hit point
# arctan2(hx, hz) so longitude increases eastward (right on screen).
# +0.5 offset centers the view on 0° longitude (Greenwich) instead of
# 180° (dateline), keeping the seam on the back of the sphere.
u = (np.arctan2(hx, hz) / (2.0 * math.pi) + 0.5) % 1.0
v = np.arcsin(np.clip(hy / np.where(hit, np.sqrt(hx**2 + hy**2 + hz**2), 1.0), -1.0, 1.0)) / math.pi + 0.5
return hit, nx.astype(np.float32), ny.astype(np.float32), nz.astype(np.float32), u.astype(np.float32), v.astype(np.float32)
# ---------------------------------------------------------------------------
# Lighting helpers
# ---------------------------------------------------------------------------
def _star_light_dir(angle_deg: float):
"""
Light direction vector from star.
angle_deg: 90 = directly to the right (classic terminator).
~110 gives dramatic 3/4 lit look.
"""
a = math.radians(angle_deg)
lx = math.cos(a)
ly = math.sin(a) * 0.25 # slight vertical offset
lz = 0.55
m = math.sqrt(lx**2 + ly**2 + lz**2)
return lx/m, ly/m, lz/m
def _apply_lighting(
rgb: np.ndarray, # (H,W,3) float32 surface color [0,1]
hit: np.ndarray, # (H,W) bool
nx, ny, nz: np.ndarray, # surface normals
surface_water: np.ndarray, # (H,W) bool — specular mask
atmo_color, # (3,) float or None
body_def: dict,
) -> np.ndarray:
"""
Full lighting pass. Returns (H,W,3) float32 lit RGB.
"""
render = body_def.get("render", {})
langle = render.get("globe_light_angle_deg", 125)
night_amb= render.get("night_side_ambient", 0.02)
do_spec = render.get("specular_ocean", True)
lx, ly, lz = _star_light_dir(langle)
star_type = body_def.get("star", {}).get("type", "G")
star_tint = np.array(STAR_TINTS.get(star_type, (1,1,1)), dtype=np.float32)
# View direction (camera at 0,0,3, looking at origin)
vz = -1.0 # simplified: view dir is ~(0,0,-1) at pixel center
# Dot products
NdotL = nx * lx + ny * ly + nz * lz # (H,W)
NdotV = np.abs(nz) # grazing = 0, face-on = 1
# --- Diffuse (sharpened Lambert) ---
# Smoothstep-stretched terminator: spreads the lit→dark transition
# across a wider band than physical Lambert. More cinematic, less harsh.
t_raw = np.clip(NdotL * 1.4 + 0.15, 0.0, 1.0) # shift+scale to widen zone
diff = t_raw * t_raw * (3.0 - 2.0 * t_raw) # smoothstep
ambient = 0.06
lit_rgb = rgb * (ambient + (1.0 - ambient) * diff[..., np.newaxis] * star_tint)
# --- Night side ambient scatter ---
dark_mask = (NdotL < 0.0)
night_rgb = rgb * (night_amb * star_tint)
lit_rgb = np.where(dark_mask[..., np.newaxis], night_rgb, lit_rgb)
# --- Terminator warm scatter band ---
term = np.abs(NdotL)
term_band = np.clip(1.0 - term / 0.10, 0.0, 1.0) ** 2 # 0-10° around terminator
term_color = np.array([1.0, 0.62, 0.28], dtype=np.float32) * star_tint
lit_rgb = lit_rgb + term_band[..., np.newaxis] * term_color * 0.35 * np.clip(NdotL + 0.10, 0, 1)[..., np.newaxis]
# --- Ocean specular ---
if do_spec and surface_water is not None:
rx = -lx + 2.0 * NdotL * nx
ry = -ly + 2.0 * NdotL * ny
rz = -lz + 2.0 * NdotL * nz
spec = np.clip(-rz, 0.0, 1.0) ** 70 # tight highlight
spec *= surface_water.astype(np.float32)
spec *= (NdotL > 0.0).astype(np.float32)
lit_rgb += spec[..., np.newaxis] * star_tint * 0.80
# --- Atmospheric rim glow ---
if atmo_color is not None:
ac = np.array(atmo_color, dtype=np.float32)
rim = (1.0 - NdotV) ** 5
# Lit side: bright rim
rim_lit = rim * np.clip(NdotL + 0.30, 0.0, 1.0)
# Dark side: fainter rim (scatter from beyond terminator)
rim_dark = rim * np.clip(-NdotL + 0.15, 0.0, 1.0) * 0.35
lit_rgb += (rim_lit + rim_dark)[..., np.newaxis] * ac * 0.60
return np.clip(lit_rgb, 0.0, 1.0)
# ---------------------------------------------------------------------------
# Star field background
# ---------------------------------------------------------------------------
def _make_starfield(size: int, seed: int) -> np.ndarray:
"""Returns (size, size, 3) float32 star field background."""
rng = np.random.default_rng(seed + 9999)
field = np.zeros((size, size, 3), dtype=np.float32)
n_stars = int(size * size * 0.0018)
ys = rng.integers(0, size, n_stars)
xs = rng.integers(0, size, n_stars)
bri = rng.uniform(0.25, 1.0, n_stars).astype(np.float32)
# Slight color variation
cr = rng.uniform(0.85, 1.00, n_stars).astype(np.float32)
cg = rng.uniform(0.88, 1.00, n_stars).astype(np.float32)
cb = rng.uniform(0.90, 1.00, n_stars).astype(np.float32)
field[ys, xs, 0] = bri * cr
field[ys, xs, 1] = bri * cg
field[ys, xs, 2] = bri * cb
return field
# ---------------------------------------------------------------------------
# Surface color from terrain data OR procedural fallback
# ---------------------------------------------------------------------------
def _surface_color_terrestrial(
u: np.ndarray, v: np.ndarray,
terrain, body_def: dict, seed: int
) -> tuple:
"""
Returns (rgb (H,W,3) float32, surface_water (H,W) bool).
If terrain is None, generates a plausible procedural surface.
"""
planet_class = body_def.get("planet_class", "temperate")
H, W = u.shape
if terrain is not None and "biome" in terrain:
# Sample terrain grids by UV coordinates (equirectangular projection).
# u = longitude [0,1], v = latitude [0,1] where 0=south pole, 1=north pole.
# Terrain grid: row 0 = north pole, row H-1 = south pole.
tH, tW = terrain["biome"].shape
# Map UV to terrain grid indices
col_idx = np.clip((u * tW).astype(np.int32), 0, tW - 1)
row_idx = np.clip(((1.0 - v) * tH).astype(np.int32), 0, tH - 1)
biome = terrain["biome"][row_idx, col_idx]
col = _EXTENDED_BIOME_COLORS[np.clip(biome, 0, len(_EXTENDED_BIOME_COLORS)-1)]
water_grid = terrain.get("surface_water", terrain["biome"] <= 2)
water = water_grid[row_idx, col_idx]
# Elevation shading — skip for ice/snow classes (17, 26) which
# should stay bright. The hillshade in the lighting pass provides
# enough depth cue on ice surfaces.
if "elevation" in terrain:
elev = terrain["elevation"][row_idx, col_idx]
shade = 0.82 + 0.18 * elev
is_ice = (biome == 17) | (biome == 26)
shade = np.where(is_ice, 1.0, shade)
col = np.clip(col * shade[..., np.newaxis], 0, 1)
# Terrain relief on rocky/dry worlds: hillshade drives surface
# contrast since biome color is uniform. Stronger on cratered
# bodies where rims catching light is the primary visual feature.
if "hillshade" in terrain:
hs = terrain["hillshade"][row_idx, col_idx]
is_rock = ((biome == 18) | (biome == 27) | (biome == 28) | (biome == 29)
| (biome == 30) | (biome == 31) | (biome == 32) | (biome == 33))
rock_variation = 0.55 + 0.45 * hs
col = np.where(is_rock[..., np.newaxis],
np.clip(col * rock_variation[..., np.newaxis], 0, 1),
col)
return col.astype(np.float32), water
# --- Procedural fallback ---
rng = np.random.default_rng(seed)
# Continent mask — low-freq noise, threshold to land_fraction
lf = body_def.get("terrain", {}).get("land_fraction", 0.35)
cont_noise = fbm(u * 3, v * 2, seed, octaves=5, gain=0.55)
# Normalise to [0,1]
cont = (cont_noise - cont_noise.min()) / (cont_noise.max() - cont_noise.min() + 1e-9)
land = cont > (1.0 - lf)
# Detail texture
detail = fbm(u * 8, v * 6, seed + 1, octaves=4, gain=0.5)
detail = (detail - detail.min()) / (detail.max() - detail.min() + 1e-9)
# Base colors by planet class
water_col = np.array([0.12, 0.25, 0.50], np.float32)
shore_col = np.array([0.45, 0.55, 0.35], np.float32)
class_land = {
"temperate": (np.array([0.28, 0.50, 0.22], np.float32),
np.array([0.50, 0.62, 0.32], np.float32)),
"forest": (np.array([0.18, 0.40, 0.18], np.float32),
np.array([0.30, 0.52, 0.24], np.float32)),
"oceanic": (np.array([0.22, 0.45, 0.20], np.float32),
np.array([0.08, 0.18, 0.42], np.float32)),
"arid": (np.array([0.70, 0.60, 0.40], np.float32),
np.array([0.82, 0.72, 0.52], np.float32)),
"martian": (np.array([0.62, 0.38, 0.25], np.float32),
np.array([0.72, 0.48, 0.32], np.float32)),
"frozen": (np.array([0.82, 0.88, 0.95], np.float32),
np.array([0.90, 0.94, 0.98], np.float32)),
"barren": (np.array([0.38, 0.35, 0.32], np.float32),
np.array([0.52, 0.48, 0.44], np.float32)),
"volcanic": (np.array([0.22, 0.18, 0.16], np.float32),
np.array([0.70, 0.30, 0.10], np.float32)),
}
dark_l, light_l = class_land.get(planet_class,
class_land["temperate"])
land_col = dark_l[np.newaxis, np.newaxis, :] * (1 - detail[..., np.newaxis]) + \
light_l[np.newaxis, np.newaxis, :] * detail[..., np.newaxis]
# Polar ice caps
lat_abs = np.abs(v - 0.5) * 2.0
ice_thresh = body_def.get("terrain", {}).get("polar_ice_lat", 0.80)
ice_blend = np.clip((lat_abs - ice_thresh) / (1.0 - ice_thresh + 0.05), 0, 1)
ice_color = np.array([0.92, 0.95, 0.98], np.float32)
land_col = land_col * (1 - ice_blend[..., np.newaxis]) + \
ice_color * ice_blend[..., np.newaxis]
# Ocean depth shading
ocean_depth = 1.0 - cont
oc = water_col[np.newaxis, np.newaxis, :] * (0.6 + 0.4 * ocean_depth[..., np.newaxis])
# Shallow coast transition
coast_blend = np.clip((cont - (1 - lf)) / 0.06, 0, 1)
land_col_c = land_col * (1 - coast_blend[..., np.newaxis]) * 0.0 + \
shore_col * (1 - coast_blend[..., np.newaxis]) + \
land_col * coast_blend[..., np.newaxis]
rgb = np.where(land[..., np.newaxis], land_col_c, oc)
# Volcanic lava cracks
if planet_class == "volcanic":
lava_noise = fbm(u * 15, v * 12, seed + 7, octaves=3)
lava = np.clip((lava_noise + 0.15) * 8.0, 0, 1)
lava_col = np.array([0.92, 0.40, 0.05], np.float32)
lava_mask = land & (lava > 0.85)
rgb = np.where(lava_mask[..., np.newaxis], lava_col, rgb)
water_mask = ~land
return rgb.astype(np.float32), water_mask
# ---------------------------------------------------------------------------
# Cloud layer
# ---------------------------------------------------------------------------
def _cloud_layer(
u: np.ndarray, v: np.ndarray,
terrain, body_def: dict, seed: int,
nx, ny, nz: np.ndarray,
NdotL: np.ndarray,
star_tint: np.ndarray,
atmo_color,
) -> np.ndarray:
"""
Returns (H,W,3) float32 additive cloud RGB.
Moisture-driven if terrain provided, else procedural.
"""
cloud_cfg = body_def.get("clouds", {})
coverage = cloud_cfg.get("coverage_base", 0.40)
planet_class= body_def.get("planet_class", "temperate")
if planet_class in ("barren", "moon", "gas_giant", "gas_giant_ringed"):
return np.zeros((*u.shape, 3), dtype=np.float32)
# Cloud opacity — procedural shapes weighted by moisture.
# Moisture influences density, not shape — otherwise clouds just
# blanket the oceans where moisture is highest.
cloud_shape = fbm(u * 4, v * 3, seed + 42, octaves=5, gain=0.58)
cloud_shape = (cloud_shape - cloud_shape.min()) / (cloud_shape.max() - cloud_shape.min() + 1e-9)
if terrain is not None and "moisture" in terrain:
tH, tW = terrain["moisture"].shape
col_idx = np.clip((u * tW).astype(np.int32), 0, tW - 1)
row_idx = np.clip(((1.0 - v) * tH).astype(np.int32), 0, tH - 1)
moist = terrain["moisture"][row_idx, col_idx]
# Moisture boosts cloud density where it's wet, but the shape
# comes from the noise field — clouds can exist over land too.
raw_cld = cloud_shape * (0.5 + 0.5 * moist)
else:
raw_cld = fbm(u * 4, v * 3, seed + 42, octaves=5, gain=0.58)
raw_cld = (raw_cld - raw_cld.min()) / (raw_cld.max() - raw_cld.min() + 1e-9)
# Threshold to target coverage
thresh = np.percentile(raw_cld, (1.0 - coverage) * 100)
alpha = np.clip((raw_cld - thresh) / (raw_cld.max() - thresh + 1e-9), 0, 1)
alpha = alpha ** 0.70 # soften edges
# Gaussian blur on cloud alpha to eliminate any residual noise texture
from scipy.ndimage import gaussian_filter
alpha = gaussian_filter(alpha, sigma=2.5).astype(np.float32)
alpha = np.clip(alpha, 0, 1)
# Cloud color — lit side bright, dark side very dim
diff = np.clip(NdotL, 0.0, 1.0)
amb = 0.08
cld_bri = (amb + (1 - amb) * diff)[..., np.newaxis] * star_tint[np.newaxis, np.newaxis, :]
cld_rgb = cld_bri * 0.96 # slightly warm white
# Rim darkening on clouds at grazing angle
NdotV = np.abs(nz)
rim = (1.0 - NdotV) ** 3 * 0.25
cld_rgb = cld_rgb * (1.0 - rim[..., np.newaxis])
return cld_rgb, alpha
# ---------------------------------------------------------------------------
# Gas giant renderer
# ---------------------------------------------------------------------------
def _render_gas_giant(
hit: np.ndarray,
nx, ny, nz: np.ndarray,
u: np.ndarray, v: np.ndarray,
body_def: dict, seed: int,
) -> np.ndarray:
"""
Returns (H,W,3) float32 lit gas giant surface color.
No UV-wrap needed — surface is procedural bands.
"""
gg_cfg = body_def.get("gas_giant", {})
palette_name = gg_cfg.get("band_palette", "jovian")
storm_count = gg_cfg.get("storm_count", 2)
storm_size = gg_cfg.get("storm_max_size", 0.10)
palette = np.array(GAS_PALETTES.get(palette_name, GAS_PALETTES["jovian"]),
dtype=np.float32)
n_bands = len(palette)
rng = np.random.default_rng(seed)
# Latitude with domain warp for natural band wobble
warp = fbm(u * 2, v * 4, seed + 100, octaves=4, gain=0.50) * 0.08
lat_warped = np.clip(v + warp, 0.0, 1.0)
# Band index from warped latitude
band_raw = lat_warped * n_bands * 2.5
band_idx = np.floor(band_raw).astype(np.int32) % n_bands
# Detail noise within bands
detail = fbm(u * 6, v * 8, seed + 200, octaves=3, gain=0.45)
detail = (detail - detail.min()) / (detail.max() - detail.min() + 1e-9)
# Base band color
rgb = palette[band_idx]
# Subtle lightening/darkening from detail
rgb = rgb * (0.88 + 0.24 * detail[..., np.newaxis])
# Storm ovals
storm_lats = rng.uniform(0.20, 0.80, storm_count)
storm_lons = rng.uniform(0.05, 0.95, storm_count)
storm_sizes = rng.uniform(storm_size * 0.5, storm_size, storm_count)
storm_cols = palette[rng.integers(0, n_bands, storm_count)]
for i in range(storm_count):
du = (u - storm_lons[i] + 0.5) % 1.0 - 0.5
dv = v - storm_lats[i]
# Distance from storm center (oval: wider than tall)
sz = storm_sizes[i]
dist = np.sqrt((du / (sz * 2.0))**2 + (dv / sz)**2)
# Spiral swirl: rotate the band pattern around the storm center.
# Angle increases toward center → spiral arms.
angle = np.arctan2(dv, du)
swirl_strength = np.clip(1.0 - dist / 1.2, 0, 1) ** 1.5
swirl_angle = swirl_strength * 3.5 # ~1 full rotation at center
# Distort the band noise by rotating UV around storm
swirl_u = du * np.cos(swirl_angle) - dv * np.sin(swirl_angle)
swirl_detail = np.sin(swirl_u * 40.0 + angle * 2.0) * 0.08
# Storm color: base + swirl texture
storm_alpha = np.clip(1.0 - dist / 0.8, 0, 1) ** 2
storm_rgb = storm_cols[i] * (1.0 + swirl_detail[..., np.newaxis])
rgb = rgb * (1 - storm_alpha[..., np.newaxis]) + \
storm_rgb * storm_alpha[..., np.newaxis]
rgb = np.clip(rgb, 0.0, 1.0)
# Lighting — diffuse only (no specular, slight rim)
render = body_def.get("render", {})
langle = render.get("globe_light_angle_deg", 125)
lx, ly, lz = _star_light_dir(langle)
star_type= body_def.get("star", {}).get("type", "G")
star_tint= np.array(STAR_TINTS.get(star_type, (1,1,1)), np.float32)
NdotL = nx * lx + ny * ly + nz * lz
t_raw = np.clip(NdotL * 1.4 + 0.15, 0.0, 1.0)
diff = t_raw * t_raw * (3.0 - 2.0 * t_raw)
amb = 0.08
night_amb = render.get("night_side_ambient", 0.025)
dark = NdotL < 0
lit_rgb = rgb * (amb + (1 - amb) * diff[..., np.newaxis] * star_tint)
lit_rgb = np.where(dark[..., np.newaxis],
rgb * night_amb,
lit_rgb)
# Atmosphere/rim glow using band palette mid color
mid_col = palette[n_bands // 2] * 0.7 + np.array([0.5, 0.5, 0.6], np.float32) * 0.3
NdotV = np.abs(nz)
rim = (1.0 - NdotV) ** 5
rim_lit = rim * np.clip(NdotL + 0.30, 0, 1)
lit_rgb += rim_lit[..., np.newaxis] * mid_col * 0.50
return np.clip(lit_rgb, 0, 1)
# ---------------------------------------------------------------------------
# Ring plane compositor
# ---------------------------------------------------------------------------
def _composite_rings(
canvas: np.ndarray,
hit: np.ndarray,
body_def: dict, seed: int,
effective_r: float = SPHERE_R,
) -> np.ndarray:
"""
Equatorial ring plane viewed from 5° above.
The ring lies in the planet's equatorial plane (horizontal).
Viewed from 5° elevation, the projection is an ellipse where:
- X axis = full ring radius (unchanged by elevation angle)
- Y axis = ring_radius * sin(ELEV) — very flat, only 8.7% of X
- Centre = planet screen centre (cx, cy) — no offset
- Near side = bottom half of ellipse (ys_g > 0) — crosses in front
- Far side = top half of ellipse (ys_g <= 0) — behind planet
"""
ELEV = math.radians(5) # camera elevation above ring plane
sin_elev = math.sin(ELEV) # 0.0872 — Y compression factor
cos_elev = math.cos(ELEV) # 0.9962 — used for lighting normal
ring_cfg = body_def.get("rings", {})
r_inner = ring_cfg.get("inner_radius_factor", 1.12)
r_outer = ring_cfg.get("outer_radius_factor", 2.65)
base_opa = ring_cfg.get("opacity_base", 0.62)
palette_name = body_def.get("gas_giant", {}).get("band_palette", "jovian")
palette = np.array(GAS_PALETTES.get(palette_name, GAS_PALETTES["jovian"]),
dtype=np.float32)
if "ring_color" in ring_cfg:
ring_col = np.array(ring_cfg["ring_color"], dtype=np.float32)
else:
ring_base = palette[0]*0.4 + palette[2]*0.4 + palette[4]*0.2
ring_col = np.clip(ring_base * 1.15, 0, 1)
H, W = canvas.shape[:2]
cx, cy = W / 2.0, H / 2.0
planet_px = (effective_r / 2.0) * W # sphere radius in pixels
# Pixel offsets from planet centre — ellipse is centred here, no shift
ys_arr = np.arange(H, dtype=np.float32) - cy
xs_arr = np.arange(W, dtype=np.float32) - cx
xs_g, ys_g = np.meshgrid(xs_arr, ys_arr)
# Ellipse axes: X = full radius, Y = radius * sin(elevation)
rx_o = r_outer * planet_px
ry_o = r_outer * planet_px * sin_elev # very flat
rx_i = r_inner * planet_px
ry_i = r_inner * planet_px * sin_elev
# Annular ring mask
e_outer = (xs_g / rx_o)**2 + (ys_g / ry_o)**2
e_inner = (xs_g / rx_i)**2 + (ys_g / ry_i)**2
in_ring = (e_outer <= 1.0) & (e_inner >= 1.0)
# Radial opacity variation
t_ring = np.clip(
(np.sqrt(e_outer) - r_inner/r_outer) / (1.0 - r_inner/r_outer + 1e-9),
0, 1)
gap = np.clip(1.0 - np.abs(t_ring - 0.55) / 0.06, 0, 1) ** 2
r_px = np.sqrt((xs_g/rx_o)**2 + (ys_g/ry_o)**2)
density = np.sin(r_px * 55.0) * 0.10 + 0.90
opa = np.clip(base_opa * density * (1.0 - gap*0.75) * in_ring, 0, 1)
# Lighting — ring plane normal is (0, sin_elev, -cos_elev) for equatorial plane
# at 5° elevation. Ring faces mostly upward so boost ambient significantly.
render = body_def.get("render", {})
langle = render.get("globe_light_angle_deg", 125)
lx, ly, lz = _star_light_dir(langle)
ring_light = abs(sin_elev * ly + (-cos_elev) * lz) * 0.40 + 0.72
lit_ring = np.clip(ring_col * ring_light, 0, 1)
result = canvas.copy()
# Far side: top half of ellipse (ys_g <= 0) — draw behind planet only
far = in_ring & (ys_g <= 0) & ~hit
result[far] = (result[far] * (1 - opa[far, np.newaxis]) +
lit_ring * opa[far, np.newaxis])
# Near side: bottom half of ellipse (ys_g > 0) — draw in front of everything
near = in_ring & (ys_g > 0)
near_on = near & hit
near_off = near & ~hit
result[near_off] = (result[near_off] * (1 - opa[near_off, np.newaxis]) +
lit_ring * opa[near_off, np.newaxis])
shadow = 1.0 - opa[near_on, np.newaxis] * 0.30
result[near_on] = (result[near_on] * shadow * (1 - opa[near_on, np.newaxis]) +
lit_ring * opa[near_on, np.newaxis])
return np.clip(result, 0, 1)
def render_globe(
body_def: dict,
terrain: dict = None,
size: int = GLOBE_SIZE,
) -> Image.Image:
"""
Render a planet globe.
Parameters
----------
body_def : dict
Body definition (see module docstring for schema).
terrain : dict or None
Geographic data grids. If None, procedural surface is used.
size : int
Output image size (default 2048).
Returns
-------
PIL.Image.Image RGBA, size×size
"""
seed = body_def.get("seed", 42)
planet_class = body_def.get("planet_class", "temperate")
oblateness = body_def.get("physical", {}).get("oblateness", 0.0)
body_scale = body_def.get("body_scale", "planet") # "planet" or "moon"
# Inflate oblateness for gas giants
if planet_class in ("gas_giant", "gas_giant_ringed"):
oblateness = max(oblateness,
body_def.get("physical", {}).get("oblateness", 0.065))
# Effective sphere radius in NDC [-1,1]:
# - ringed bodies: shrink so outer ring fits within 0.84 NDC margin
# - moons: 2/3 scale of planet for visual distinction in grids
if planet_class == "gas_giant_ringed":
# Fit outer ring within 82% of half-image width.
# outer_ring_px = r_outer * (effective_r/2) * W = 0.82 * (W/2)
# => effective_r = 0.82 / r_outer
r_outer_fit = body_def.get("rings", {}).get("outer_radius_factor", 2.65)
effective_r = 0.82 / r_outer_fit
else:
effective_r = SPHERE_R # default 0.90
if body_scale in ("moon", "dwarf") or body_def.get("body_type") == "moon":
effective_r *= 0.50
# -- Ray trace --------------------------------------------------------
hit, nx, ny, nz, u, v = _raytrace(size, effective_r, oblateness)
# Zero out normals on miss pixels to avoid NaN propagation
nx = np.where(hit, nx, 0.0)
ny = np.where(hit, ny, 0.0)
nz = np.where(hit, nz, 1.0)
u = np.where(hit, u, 0.0)
v = np.where(hit, v, 0.5)
# -- Background -------------------------------------------------------
canvas = _make_starfield(size, seed)
# Terrain grids stay at their native resolution (256×512 equirectangular).
# Surface and cloud functions sample by UV coordinates, not pixel alignment.
# -- Surface color ----------------------------------------------------
is_gas = planet_class in ("gas_giant", "gas_giant_ringed")
if is_gas:
surface_rgb = _render_gas_giant(hit, nx, ny, nz, u, v, body_def, seed)
surface_water = None
else:
surface_rgb, surface_water = _surface_color_terrestrial(
u, v, terrain, body_def, seed)
# -- Lighting ---------------------------------------------------------
atmo_color = ATMO_COLORS.get(planet_class)
if is_gas:
lit_rgb = surface_rgb # gas giant handles own lighting internally
else:
render = body_def.get("render", {})
langle = render.get("globe_light_angle_deg", 125)
lx, ly, lz = _star_light_dir(langle)
star_type = body_def.get("star", {}).get("type", "G")
star_tint = np.array(STAR_TINTS.get(star_type, (1,1,1)), np.float32)
lit_rgb = _apply_lighting(
surface_rgb, hit, nx, ny, nz,
surface_water, atmo_color, body_def)
# -- Clouds -------------------------------------------------------
NdotL = nx * lx + ny * ly + nz * lz
cld_cfg = body_def.get("clouds", {})
if cld_cfg.get("enabled", False):
cld_rgb, cld_alpha = _cloud_layer(u, v, terrain, body_def, seed,
nx, ny, nz, NdotL, star_tint, atmo_color)
# Alpha-blend: clouds occlude surface, not just add brightness
a = cld_alpha[..., np.newaxis]
lit_rgb = lit_rgb * (1.0 - a) + cld_rgb * a
lit_rgb = np.clip(lit_rgb, 0, 1)
# -- Composite onto canvas --------------------------------------------
canvas[hit] = lit_rgb[hit]
# -- Ring plane -------------------------------------------------------
if planet_class == "gas_giant_ringed":
canvas = _composite_rings(canvas, hit, body_def, seed, effective_r)
# -- Atmosphere glow halo (outside sphere edge) ----------------------
if atmo_color is not None:
ac = np.array(atmo_color, dtype=np.float32)
# Distance from pixel to sphere center
lin = np.linspace(-1.0, 1.0, size, dtype=np.float32)
px2, py2 = np.meshgrid(lin, -lin)
dist_c = np.sqrt(px2**2 + py2**2)
halo = np.clip((effective_r + 0.045 - dist_c) / 0.045, 0, 1)
halo *= (~hit).astype(np.float32)
# Light-side bias
langle = body_def.get("render", {}).get("globe_light_angle_deg", 125)
la = math.radians(langle)
halo_bias = np.clip(px2 * math.cos(la) * 0.5 + 0.5, 0.2, 1.0)
halo *= halo_bias
canvas = canvas + halo[..., np.newaxis] * ac * 0.40
canvas = np.clip(canvas, 0, 1)
# -- Convert to PIL ---------------------------------------------------
canvas_uint8 = (canvas * 255.0).clip(0, 255).astype(np.uint8)
# Alpha: opaque on hit pixels; ring pixels get opacity from their blend weight
alpha = np.where(hit, 255, 0).astype(np.uint8)
# For ringed bodies, mark ring pixels as opaque too
if planet_class == "gas_giant_ringed":
# Alpha for ring pixels — same equatorial geometry as _composite_rings
ELEV_A = math.radians(5)
ring_cfg = body_def.get("rings", {})
r_inner_a = ring_cfg.get("inner_radius_factor", 1.12)
r_outer_a = ring_cfg.get("outer_radius_factor", 2.65)
base_opa_a = ring_cfg.get("opacity_base", 0.62)
planet_px_a = (effective_r / 2.0) * size
sin_elev_a = math.sin(ELEV_A)
ys_a = np.arange(size, dtype=np.float32) - size / 2.0
xs_a = np.arange(size, dtype=np.float32) - size / 2.0
xs_ga, ys_ga = np.meshgrid(xs_a, ys_a)
rx_oa = r_outer_a * planet_px_a
ry_oa = r_outer_a * planet_px_a * sin_elev_a
rx_ia = r_inner_a * planet_px_a
ry_ia = r_inner_a * planet_px_a * sin_elev_a
e_oa = (xs_ga/rx_oa)**2 + (ys_ga/ry_oa)**2
e_ia = (xs_ga/rx_ia)**2 + (ys_ga/ry_ia)**2
ring_px = (e_oa <= 1.0) & (e_ia >= 1.0)
r_na = np.sqrt(e_oa)
den_a = np.sin(r_na * 55.0) * 0.10 + 0.90
gt_a = np.clip((r_na - r_inner_a/r_outer_a)/(1.0 - r_inner_a/r_outer_a + 1e-9), 0, 1)
gap_a = np.clip(1.0 - np.abs(gt_a - 0.55)/0.06, 0, 1)**2
opa_a = np.clip(base_opa_a * den_a * (1-gap_a*0.75) * ring_px, 0, 1)
alpha = np.maximum(alpha, (opa_a * 255).astype(np.uint8))
# Partial alpha on halo
if atmo_color is not None:
lin = np.linspace(-1.0, 1.0, size, dtype=np.float32)
px2, py2 = np.meshgrid(lin, -lin)
dist_c = np.sqrt(px2**2 + py2**2)
halo_a = np.clip((effective_r + 0.045 - dist_c) / 0.045, 0, 1)
halo_a *= (~hit).astype(np.float32)
alpha = np.maximum(alpha, (halo_a * 200).astype(np.uint8))
rgba = np.dstack([canvas_uint8, alpha])
return Image.fromarray(rgba, mode="RGBA")
# ---------------------------------------------------------------------------
# CLI test — renders one body of each class for visual QA
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import sys
import os
import time
TEST_BODIES = [
{
"id": "test_temperate", "name": "Test Temperate",
"planet_class": "temperate", "seed": 144042,
"star": {"type": "G", "luminosity_solar": 1.0},
"orbit": {"distance_au": 1.0, "axial_tilt_deg": 23},
"physical": {"gravity_g": 1.0, "oblateness": 0.003},
"terrain": {"land_fraction": 0.40, "polar_ice_lat": 0.78},
"clouds": {"enabled": True, "coverage_base": 0.45},
"render": {"globe_light_angle_deg": 125, "specular_ocean": True,
"night_side_ambient": 0.025},
},
{
"id": "test_arid", "name": "Test Arid",
"planet_class": "arid", "seed": 55001,
"star": {"type": "G", "luminosity_solar": 1.1},
"orbit": {"distance_au": 1.3, "axial_tilt_deg": 5},
"physical": {"gravity_g": 0.85, "oblateness": 0.002},
"terrain": {"land_fraction": 0.70, "polar_ice_lat": 0.92},
"clouds": {"enabled": False},
"render": {"globe_light_angle_deg": 125, "specular_ocean": False,
"night_side_ambient": 0.015},
},
{
"id": "test_frozen", "name": "Test Frozen",
"planet_class": "frozen", "seed": 88800,
"star": {"type": "K", "luminosity_solar": 0.4},
"orbit": {"distance_au": 0.6, "axial_tilt_deg": 45},
"physical": {"gravity_g": 0.90, "oblateness": 0.002},
"terrain": {"land_fraction": 0.30, "polar_ice_lat": 0.30},
"clouds": {"enabled": True, "coverage_base": 0.30},
"render": {"globe_light_angle_deg": 125, "specular_ocean": True,
"night_side_ambient": 0.018},
},
{
"id": "test_barren", "name": "Test Barren",
"planet_class": "barren", "seed": 31415,
"star": {"type": "G", "luminosity_solar": 1.0},
"orbit": {"distance_au": 0.5, "axial_tilt_deg": 2},
"physical": {"gravity_g": 0.40, "oblateness": 0.001},
"terrain": {"land_fraction": 0.99, "polar_ice_lat": 0.98},
"clouds": {"enabled": False},
"render": {"globe_light_angle_deg": 125, "specular_ocean": False,
"night_side_ambient": 0.005},
},
{
"id": "test_volcanic", "name": "Test Volcanic",
"planet_class": "volcanic", "seed": 66666,
"star": {"type": "M", "luminosity_solar": 0.08},
"orbit": {"distance_au": 0.15, "axial_tilt_deg": 10},
"physical": {"gravity_g": 1.1, "oblateness": 0.004},
"terrain": {"land_fraction": 0.85, "polar_ice_lat": 0.99},
"clouds": {"enabled": True, "coverage_base": 0.70},
"render": {"globe_light_angle_deg": 125, "specular_ocean": False,
"night_side_ambient": 0.040},
},
{
"id": "test_gas_giant", "name": "Test Gas Giant",
"planet_class": "gas_giant", "seed": 20001,
"star": {"type": "G", "luminosity_solar": 1.0},
"physical": {"oblateness": 0.065},
"gas_giant": {"band_palette": "jovian", "storm_count": 3,
"storm_max_size": 0.10},
"render": {"globe_light_angle_deg": 125, "night_side_ambient": 0.025},
},
{
"id": "test_moon", "name": "Test Moon",
"planet_class": "barren", "seed": 99001,
"body_scale": "moon",
"star": {"type": "G", "luminosity_solar": 1.0},
"orbit": {"distance_au": 1.0, "axial_tilt_deg": 5},
"physical": {"gravity_g": 0.16, "oblateness": 0.001},
"terrain": {"land_fraction": 0.99, "polar_ice_lat": 0.99},
"clouds": {"enabled": False},
"render": {"globe_light_angle_deg": 125, "specular_ocean": False,
"night_side_ambient": 0.005},
},
{
"id": "test_gas_giant_ringed", "name": "Test Ringed Giant",
"planet_class": "gas_giant_ringed", "seed": 77777,
"star": {"type": "G", "luminosity_solar": 1.0},
"physical": {"oblateness": 0.070},
"gas_giant": {"band_palette": "neptunian", "storm_count": 2,
"storm_max_size": 0.08},
"rings": {"enabled": True, "inner_radius_factor": 1.12,
"outer_radius_factor": 2.65, "opacity_base": 0.62,
"ring_color": [0.72, 0.82, 0.95]},
"render": {"globe_light_angle_deg": 125, "night_side_ambient": 0.020},
},
]
out_dir = "/mnt/user-data/outputs"
os.makedirs(out_dir, exist_ok=True)
# Use 512 for fast QA render; change to 2048 for final
qa_size = int(sys.argv[1]) if len(sys.argv) > 1 else 512
paths = []
for bd in TEST_BODIES:
t0 = time.time()
img = render_globe(bd, terrain=None, size=qa_size)
out = os.path.join(out_dir, f"{bd['id']}.png")
img.save(out, format="PNG")
dt = time.time() - t0
console.event(f" {bd['id']:30s} {qa_size}×{qa_size} {dt:.1f}s")
paths.append(out)
console.event(f"Done. {len(paths)} planets rendered at {qa_size}px.")