""" 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 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 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, ) # --------------------------------------------------------------------------- # 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 u = (np.arctan2(hz, hx) / (2.0 * math.pi)) % 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, os, 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 print(f" {bd['id']:30s} {qa_size}×{qa_size} {dt:.1f}s") paths.append(out) print(f"\nDone. {len(paths)} planets rendered at {qa_size}px.")