From f84275edf3f0ca8211e08951121a0bb25de3175d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 6 Apr 2026 15:54:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(assets):=20planet=20generator=20pipeline?= =?UTF-8?q?=20=E2=80=94=20heightmap=20+=20globe=20from=20wiki=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tooling/planet-gen/batch | 5 + tooling/planet-gen/batch.py | 564 +++++++++++ tooling/planet-gen/biome_config.py | 99 ++ tooling/planet-gen/biomes.toml | 326 +++++++ tooling/planet-gen/body_definition_parser.py | 777 +++++++++++++++ tooling/planet-gen/generate | 5 + tooling/planet-gen/generate.py | 300 ++++++ tooling/planet-gen/planet_renderer.py | 970 +++++++++++++++++++ tooling/planet-gen/planet_simulation.py | 956 ++++++++++++++++++ tooling/planet-gen/render_heightmap.py | 486 ++++++++++ tooling/planet-gen/scaffold_bodies.py | 194 ++++ 11 files changed, 4682 insertions(+) create mode 100755 tooling/planet-gen/batch create mode 100644 tooling/planet-gen/batch.py create mode 100644 tooling/planet-gen/biome_config.py create mode 100644 tooling/planet-gen/biomes.toml create mode 100644 tooling/planet-gen/body_definition_parser.py create mode 100755 tooling/planet-gen/generate create mode 100644 tooling/planet-gen/generate.py create mode 100644 tooling/planet-gen/planet_renderer.py create mode 100644 tooling/planet-gen/planet_simulation.py create mode 100644 tooling/planet-gen/render_heightmap.py create mode 100644 tooling/planet-gen/scaffold_bodies.py diff --git a/tooling/planet-gen/batch b/tooling/planet-gen/batch new file mode 100755 index 000000000..0136945b6 --- /dev/null +++ b/tooling/planet-gen/batch @@ -0,0 +1,5 @@ +#!/bin/bash +# Batch planet generation — process all systems unattended. +# Usage: tooling/planet-gen/batch [--scaffold-only] [--generate-only] [--system GJ-144] +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec python3 "$SCRIPT_DIR/batch.py" "$@" diff --git a/tooling/planet-gen/batch.py b/tooling/planet-gen/batch.py new file mode 100644 index 000000000..9d7ebd3c7 --- /dev/null +++ b/tooling/planet-gen/batch.py @@ -0,0 +1,564 @@ +#!/usr/bin/env python3 +""" +Batch planet generation — process all systems unattended. + +Walks wiki/star-systems/, scaffolds body index.md files where missing, +then generates heightmap + globe + terrain data for every body. + +Usage: + python3 batch.py # full run: scaffold + generate + python3 batch.py --scaffold-only # just create body index.md files + python3 batch.py --generate-only # just render (bodies must exist) + python3 batch.py --system GJ-144 # single system + python3 batch.py --system GJ-144 --body GJ144d # single body + python3 batch.py --overrides sol.json # per-body overrides + python3 batch.py --dry-run # validate data, don't generate + +Skips: + - GJ-0 (Sol) — manual overrides required, use --system GJ-0 explicitly + - asteroid_belt, oort_cloud (no renderable surface) + - Bodies that already have globe.png (use --force to regenerate) + +Error handling: + - Errors are printed to console and logged to /tmp/planet-gen-errors.log + - Batch aborts if error rate exceeds 50% of attempted bodies +""" + +import argparse +import json +import os +import sys +import time +import traceback +from datetime import datetime +from pathlib import Path + +# Venv bootstrap +TOOLING_DIR = Path(__file__).resolve().parent +WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve() +_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python" +if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve(): + os.execv(str(_venv_python), [str(_venv_python)] + sys.argv) + +import yaml +import numpy as np + +from body_definition_parser import parse_system +from planet_simulation import simulate +from render_heightmap import render_heightmap + +# Systems to skip in batch mode (require manual handling) +SKIP_SYSTEMS = {"GJ-0"} + +LOG_PATH = Path("/tmp/planet-gen-errors.log") + + +# ───────────────────────────────────────────────────────────────────────────── +# Logging +# ───────────────────────────────────────────────────────────────────────────── + +def _log_error(system_id: str, body_id: str, error: str, tb: str): + """Append error to log file with context.""" + with open(LOG_PATH, "a") as f: + f.write(f"\n{'='*72}\n") + f.write(f" time: {datetime.now().isoformat()}\n") + f.write(f" system: {system_id}\n") + f.write(f" body: {body_id}\n") + f.write(f" error: {error}\n") + f.write(f" traceback:\n{tb}\n") + + +# ───────────────────────────────────────────────────────────────────────────── +# System helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _read_system_name(index_md: Path) -> str: + """Extract the system name from the # heading in index.md.""" + for line in index_md.read_text().splitlines()[:5]: + if line.startswith("# "): + return line[2:].strip() + return index_md.parent.name + + +def _read_frontmatter(md_path: Path) -> dict: + """Read YAML frontmatter from a body index.md.""" + content = md_path.read_text() + if not content.startswith("---"): + return {} + try: + end = content.index("---", 3) + return yaml.safe_load(content[3:end]) or {} + except (ValueError, yaml.YAMLError): + return {} + + +# ───────────────────────────────────────────────────────────────────────────── +# Validation (dry-run) +# ───────────────────────────────────────────────────────────────────────────── + +REQUIRED_FIELDS = ["id", "planet_class", "seed", "star", "orbit", "physical", "terrain"] +REQUIRED_STAR = ["type", "luminosity_solar"] +REQUIRED_ORBIT = ["distance_au", "period_days", "axial_tilt_deg"] +REQUIRED_PHYSICAL = ["gravity_g", "atmosphere"] +REQUIRED_TERRAIN = ["land_fraction", "tectonics"] + + +def _validate_body_def(bd: dict, body_dir: Path) -> list: + """Validate a body definition. Returns list of error strings.""" + errors = [] + bid = bd.get("id", "?") + + for f in REQUIRED_FIELDS: + if f not in bd: + errors.append(f"{bid}: missing top-level field '{f}'") + + star = bd.get("star", {}) + for f in REQUIRED_STAR: + if f not in star: + errors.append(f"{bid}: missing star.{f}") + + orbit = bd.get("orbit", {}) + for f in REQUIRED_ORBIT: + v = orbit.get(f) + if v is None or v == "rand": + errors.append(f"{bid}: orbit.{f} is {v!r} — must be resolved (not 'rand')") + elif isinstance(v, (int, float)) and v <= 0: + errors.append(f"{bid}: orbit.{f} = {v} — must be positive") + + phys = bd.get("physical", {}) + for f in REQUIRED_PHYSICAL: + if f not in phys: + errors.append(f"{bid}: missing physical.{f}") + + terrain = bd.get("terrain", {}) + for f in REQUIRED_TERRAIN: + if f not in terrain: + errors.append(f"{bid}: missing terrain.{f}") + + lf = terrain.get("land_fraction") + if lf is not None and (lf < 0 or lf > 1): + errors.append(f"{bid}: terrain.land_fraction = {lf} — must be [0, 1]") + + seed = bd.get("seed") + if seed is None or not isinstance(seed, int): + errors.append(f"{bid}: seed must be an integer, got {seed!r}") + + pclass = bd.get("planet_class", "") + valid_classes = {"temperate", "oceanic", "forest", "arid", "frozen", + "volcanic", "barren", "gas_giant", "gas_giant_ringed"} + if pclass not in valid_classes: + errors.append(f"{bid}: planet_class '{pclass}' not in {valid_classes}") + + return errors + + +# ───────────────────────────────────────────────────────────────────────────── +# Scaffold +# ───────────────────────────────────────────────────────────────────────────── + +def _scaffold_system(system_dir: Path, overrides: dict) -> list: + """Scaffold body index.md files for one system. Returns body IDs created.""" + index_md = system_dir / "index.md" + if not index_md.exists(): + return [] + + from scaffold_bodies import _body_to_frontmatter, _body_prose + + body_defs = parse_system(str(index_md), overrides=overrides) + bodies_dir = system_dir / "bodies" + created = [] + + for bd in body_defs: + body_dir = bodies_dir / bd["id"] + body_index = body_dir / "index.md" + if body_index.exists(): + continue + body_dir.mkdir(parents=True, exist_ok=True) + fm = _body_to_frontmatter(bd) + prose = _body_prose(bd, system_dir) + body_index.write_text(f"---\n{fm}\n---\n\n{prose}") + created.append(bd["id"]) + + return created + + +# ───────────────────────────────────────────────────────────────────────────── +# Generate +# ───────────────────────────────────────────────────────────────────────────── + +def _atomic_save_img(img, target: Path): + """Write image to .tmp, then rename. Prevents corrupt files on crash.""" + tmp = target.with_name(target.name + ".tmp") + img.save(str(tmp), format="PNG") + tmp.rename(target) + + +def _atomic_save(data, target: Path, save_fn): + """Write data to .tmp, then rename. Prevents corrupt files on crash.""" + tmp = target.with_name(target.name + ".tmp") + save_fn(data, str(tmp)) + tmp.rename(target) + + +def _is_complete(body_dir: Path, is_gas: bool) -> bool: + """Check if all expected outputs exist (for resume support).""" + if not (body_dir / "globe.png").exists(): + return False + if not is_gas: + for f in ("heightmap.png", "terrain.npz", "markers.json"): + if not (body_dir / f).exists(): + return False + return True + + +def _generate_body_from_dir(body_dir: Path, hmap_w: int, hmap_h: int, + globe_size: int, force: bool) -> str: + """ + Generate assets for one body. + Returns: "generated", "skipped", or "error" + """ + index_md = body_dir / "index.md" + if not index_md.exists(): + return "skipped" + + bd = _read_frontmatter(index_md) + if not bd or "id" not in bd or "planet_class" not in bd: + return "skipped" + + body_id = bd["id"] + name = bd.get("name") or body_id + planet_class = bd.get("planet_class", "") + is_gas = planet_class in ("gas_giant", "gas_giant_ringed") + + # Resume: skip if all outputs exist (unless --force) + if not force and _is_complete(body_dir, is_gas): + return "skipped" + + t0 = time.time() + + # Simulate + terrain = simulate(bd) + is_gas = not terrain # re-check from actual simulation result + + # Heightmap — atomic write + if not is_gas: + import render_heightmap as rh + rh.OUT_W = hmap_w + rh.OUT_H = hmap_h + rh.UI_SCALE = hmap_w / 1024 + rh.RENDER_MODE = "cartographic" + rh.BIOME_RGB = rh._build_biome_rgb("cartographic") + rh.OCEAN_DEEP, rh.OCEAN_MID, rh.OCEAN_SHALLOW = rh._ocean_arrays("cartographic") + + hmap_img = render_heightmap(bd, terrain, chrome=False) + _atomic_save_img(hmap_img, body_dir / "heightmap.png") + + # Globe — atomic write + from planet_renderer import render_globe + globe_img = render_globe(bd, terrain, size=globe_size) + _atomic_save_img(globe_img, body_dir / "globe.png") + + # Terrain data — atomic write + if not is_gas: + save_dict = {} + for key in ("elevation", "temperature", "moisture", "hillshade", + "biome", "surface_water", "river_grid"): + if key in terrain: + save_dict[key] = terrain[key] + save_dict["sea_level"] = np.array([terrain["sea_level"]]) + # npz: numpy appends .npz to the path, so write directly + # (atomic rename doesn't work cleanly with numpy's extension handling) + npz_path = body_dir / "terrain.npz" + npz_tmp = body_dir / "terrain_tmp" + np.savez_compressed(str(npz_tmp), **save_dict) + Path(str(npz_tmp) + ".npz").rename(npz_path) + + # Markers — atomic write + from generate import _build_markers + markers = _build_markers(bd, terrain) + _atomic_save(markers, body_dir / "markers.json", + lambda m, p: Path(p).write_text(json.dumps(m, indent=2))) + + elapsed = time.time() - t0 + kind = "gas" if is_gas else f"land={int((~terrain['surface_water']).sum())}" + print(f" {body_id:20s} ({name:20s}) {elapsed:5.1f}s {kind}") + return "generated" + + +# ───────────────────────────────────────────────────────────────────────────── +# Main +# ───────────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Batch planet generation — all systems unattended") + parser.add_argument("--system", help="Process only this system (dir name, e.g. GJ-144)") + parser.add_argument("--body", help="Process only this body (requires --system)") + parser.add_argument("--scaffold-only", action="store_true") + parser.add_argument("--generate-only", action="store_true") + parser.add_argument("--overrides", help="Per-body overrides JSON") + parser.add_argument("--force", action="store_true", + help="Regenerate even if globe.png exists") + parser.add_argument("--dry-run", action="store_true", + help="Validate body definitions without generating") + parser.add_argument("--verify-determinism", type=int, metavar="N", default=0, + help="Run N random bodies twice and verify identical output") + parser.add_argument("--heightmap-size", default="4096x2048") + parser.add_argument("--globe-size", type=int, default=512) + args = parser.parse_args() + + hw, hh = args.heightmap_size.lower().split("x") + hmap_w, hmap_h = int(hw), int(hh) + + wiki_systems = WORKTREE_ROOT / "wiki" / "star-systems" + if not wiki_systems.exists(): + print(f"error: {wiki_systems} not found", file=sys.stderr) + sys.exit(1) + + overrides = {} + if args.overrides: + with open(args.overrides) as f: + overrides = json.load(f) + + # Collect system directories + if args.system: + system_dirs = [wiki_systems / args.system] + if not system_dirs[0].exists(): + print(f"error: system {args.system} not found", file=sys.stderr) + sys.exit(1) + else: + system_dirs = sorted([ + d for d in wiki_systems.iterdir() + if d.is_dir() and (d / "index.md").exists() + ]) + + # Clear error log + if LOG_PATH.exists(): + LOG_PATH.unlink() + + t_total = time.time() + total_scaffolded = 0 + total_generated = 0 + total_skipped = 0 + total_errors = 0 + total_attempted = 0 + total_valid = 0 + total_invalid = 0 + error_rate_threshold = 0.50 + + print(f"\n Planet Generator — Batch Mode") + print(f" Systems: {len(system_dirs)}") + print(f" Heightmap: {hmap_w}×{hmap_h} Globe: {args.globe_size}×{args.globe_size}") + if args.dry_run: + print(f" Mode: DRY RUN (validation only)") + print() + + for system_dir in system_dirs: + system_id = system_dir.name + + # Skip Sol in batch mode (needs manual overrides) + if system_id in SKIP_SYSTEMS and not args.system: + print(f" {system_id} — skipped (manual)") + continue + + system_name = _read_system_name(system_dir / "index.md") + + # Count bodies before starting + bodies_dir_check = system_dir / "bodies" + if bodies_dir_check.exists(): + n_bodies = sum(1 for d in bodies_dir_check.iterdir() + if d.is_dir() and (d / "index.md").exists()) + else: + # Peek at the system table to estimate body count + try: + defs = parse_system(str(system_dir / "index.md"), overrides=overrides) + n_bodies = len(defs) + except Exception: + n_bodies = 0 + + print(f" {system_id} — {system_name} ({n_bodies} bodies)") + + # ── Scaffold ───────────────────────────────────────────────────── + if not args.generate_only and not args.dry_run: + try: + created = _scaffold_system(system_dir, overrides) + if created: + total_scaffolded += len(created) + for bid in created: + print(f" scaffolded {bid}") + except Exception as e: + tb = traceback.format_exc() + print(f" SCAFFOLD ERROR: {e}") + _log_error(system_id, "*", str(e), tb) + + # ── Validate / Generate ────────────────────────────────────────── + bodies_dir = system_dir / "bodies" + if not bodies_dir.exists(): + if not args.scaffold_only and not args.dry_run: + # Try scaffold first if bodies dir doesn't exist + try: + _scaffold_system(system_dir, overrides) + except Exception: + pass + if not bodies_dir.exists(): + continue + + body_dirs = sorted(d for d in bodies_dir.iterdir() if d.is_dir()) + if args.body: + body_dirs = [d for d in body_dirs if d.name == args.body] + + for body_dir in body_dirs: + index_md = body_dir / "index.md" + if not index_md.exists(): + continue + + bd = _read_frontmatter(index_md) + if not bd or "id" not in bd: + continue + + body_id = bd.get("id", "?") + body_name = bd.get("name") or body_id + + if args.dry_run: + # Validate only + errors = _validate_body_def(bd, body_dir) + if errors: + total_invalid += 1 + print(f" {body_id:20s} ({body_name:20s}) INVALID") + for err in errors: + print(f" - {err}") + else: + total_valid += 1 + print(f" {body_id:20s} ({body_name:20s}) ok") + continue + + if args.scaffold_only: + continue + + # Generate with error handling + total_attempted += 1 + try: + result = _generate_body_from_dir( + body_dir, hmap_w, hmap_h, args.globe_size, args.force) + if result == "generated": + total_generated += 1 + elif result == "skipped": + total_skipped += 1 + elif result == "error": + total_errors += 1 + except Exception as e: + total_errors += 1 + tb = traceback.format_exc() + print(f" {body_id:20s} ({body_name:20s}) ERROR: {e}") + _log_error(system_id, body_id, str(e), tb) + + # Circuit breaker: abort if error rate is too high + if total_attempted >= 10 and total_errors / total_attempted > error_rate_threshold: + print(f"\n ABORT: error rate {total_errors}/{total_attempted} " + f"({total_errors/total_attempted*100:.0f}%) exceeds " + f"{error_rate_threshold*100:.0f}% threshold") + print(f" Check {LOG_PATH} for details") + sys.exit(1) + + elapsed = time.time() - t_total + + print(f"\n Batch complete: {elapsed:.0f}s") + if args.dry_run: + print(f" valid: {total_valid}") + print(f" invalid: {total_invalid}") + else: + print(f" scaffolded: {total_scaffolded}") + print(f" generated: {total_generated}") + print(f" skipped: {total_skipped}") + print(f" errors: {total_errors}") + if total_errors > 0: + print(f" error log: {LOG_PATH}") + + # ── Determinism verification ───────────────────────────────────────── + if args.verify_determinism > 0: + _verify_determinism(wiki_systems, args.verify_determinism, + hmap_w, hmap_h, args.globe_size) + + +def _verify_determinism(wiki_systems: Path, n_samples: int, + hmap_w: int, hmap_h: int, globe_size: int): + """Run N bodies twice, verify outputs are bit-identical.""" + import hashlib + import tempfile + import shutil + + print(f"\n Determinism verification — {n_samples} samples") + + # Collect all body dirs that have been generated + all_body_dirs = [] + for system_dir in wiki_systems.iterdir(): + bodies = system_dir / "bodies" + if bodies.exists(): + for bd in bodies.iterdir(): + if bd.is_dir() and (bd / "globe.png").exists(): + all_body_dirs.append(bd) + + if not all_body_dirs: + print(" no generated bodies to verify") + return + + rng = np.random.default_rng(42) + samples = rng.choice(len(all_body_dirs), min(n_samples, len(all_body_dirs)), + replace=False) + + passed = 0 + failed = 0 + for idx in samples: + body_dir = all_body_dirs[idx] + bd = _read_frontmatter(body_dir / "index.md") + if not bd: + continue + body_id = bd["id"] + + # Generate into a temp dir + tmp_dir = Path(tempfile.mkdtemp(prefix=f"detcheck_{body_id}_")) + tmp_body = tmp_dir / body_id + tmp_body.mkdir() + + # Copy index.md so the generator can read it + shutil.copy2(body_dir / "index.md", tmp_body / "index.md") + + try: + _generate_body_from_dir(tmp_body, hmap_w, hmap_h, globe_size, force=True) + except Exception as e: + print(f" {body_id}: generation failed — {e}") + shutil.rmtree(tmp_dir) + failed += 1 + continue + + # Compare file hashes + all_match = True + for fname in ("globe.png", "heightmap.png", "terrain.npz", "markers.json"): + orig = body_dir / fname + rerun = tmp_body / fname + if not orig.exists() and not rerun.exists(): + continue + if not orig.exists() or not rerun.exists(): + print(f" {body_id}: {fname} — missing in {'original' if not orig.exists() else 'rerun'}") + all_match = False + continue + h1 = hashlib.sha256(orig.read_bytes()).hexdigest()[:16] + h2 = hashlib.sha256(rerun.read_bytes()).hexdigest()[:16] + if h1 != h2: + print(f" {body_id}: {fname} — MISMATCH (orig={h1} rerun={h2})") + all_match = False + + if all_match: + passed += 1 + else: + failed += 1 + + shutil.rmtree(tmp_dir) + + print(f" passed: {passed} failed: {failed}") + if failed > 0: + print(f" WARNING: non-deterministic output detected!") + + +if __name__ == "__main__": + main() diff --git a/tooling/planet-gen/biome_config.py b/tooling/planet-gen/biome_config.py new file mode 100644 index 000000000..fccaceff6 --- /dev/null +++ b/tooling/planet-gen/biome_config.py @@ -0,0 +1,99 @@ +""" +biome_config.py — loads biomes.toml and provides lookup structures. + +Single source of truth for biome classification, colors, and rendering +parameters. All three pipeline modules import from here instead of +maintaining their own hardcoded tables. + +Usage: + from biome_config import ( + WHITTAKER_TABLE, CLASS_T_BAND, BIOME_PALETTE, + STAR_TINTS, ATMO_COLORS, GAS_PALETTES, + EXOTIC_CLASSES, CRATER_SCALING, RIVER_RGB, COAST_RGB, + ) +""" + +import os +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib # Python < 3.11 fallback + +_CONFIG_PATH = Path(__file__).resolve().parent / "biomes.toml" + + +def _load(): + with open(_CONFIG_PATH, "rb") as f: + return tomllib.load(f) + + +_CFG = _load() + + +# ── Whittaker table ────────────────────────────────────────────────── +# List of (temp_lo, temp_hi, moist_lo, moist_hi, class_id) tuples. +WHITTAKER_TABLE = [ + (w["temp_lo"], w["temp_hi"], w["moist_lo"], w["moist_hi"], w["id"]) + for w in _CFG["whittaker"] +] + +# ── Temperature bands per planet class ─────────────────────────────── +CLASS_T_BAND = { + k: tuple(v) for k, v in _CFG["temperature_bands"].items() +} + +# ── Biome color palette ────────────────────────────────────────────── +# {class_id: {"cartographic": (R,G,B), "photographic": (R,G,B), "name": str}} +BIOME_PALETTE = {} +for key, val in _CFG["biome_colors"].items(): + cid = int(key) + BIOME_PALETTE[cid] = { + "cartographic": tuple(val["cartographic"]), + "photographic": tuple(val["photographic"]), + "name": val.get("name", f"class_{cid}"), + } + +# Max class ID for array sizing +MAX_BIOME_ID = max(BIOME_PALETTE.keys()) + + +def build_biome_rgb(mode: str = "cartographic") -> dict: + """Returns {class_id: (R, G, B)} for the given render mode.""" + return {k: v[mode] for k, v in BIOME_PALETTE.items()} + + +# ── Render colors ──────────────────────────────────────────────────── +RIVER_RGB = tuple(_CFG["render_colors"]["river"]) +COAST_RGB = tuple(_CFG["render_colors"]["coastline"]) + +# ── Star tints ─────────────────────────────────────────────────────── +STAR_TINTS = {k: tuple(v) for k, v in _CFG["star_tints"].items()} + +# ── Atmosphere colors ──────────────────────────────────────────────── +ATMO_COLORS = {k: tuple(v) for k, v in _CFG["atmosphere_colors"].items()} +# Planet classes without atmosphere glow +for _no_atmo in ("barren", "moon", "gas_giant", "gas_giant_ringed"): + ATMO_COLORS.setdefault(_no_atmo, None) + +# ── Gas giant palettes ─────────────────────────────────────────────── +GAS_PALETTES = { + k: [tuple(band) for band in v] + for k, v in _CFG["gas_palettes"].items() + if k != "selection_order" +} + +# Locked selection list — order determines which body gets which palette. +# Only append, never reorder or remove. +GAS_PALETTE_SELECTION = list(_CFG["gas_palettes"]["selection_order"]) + +# ── Exotic biome class IDs ─────────────────────────────────────────── +EXOTIC_CLASSES = dict(_CFG["exotic_classes"]) + +# ── Crater scaling ─────────────────────────────────────────────────── +CRATER_SCALING = { + "base_count": _CFG["crater_scaling"]["base_count"], + "atmosphere": dict(_CFG["crater_scaling"]["atmosphere"]), + "tectonics": dict(_CFG["crater_scaling"]["tectonics"]), +} diff --git a/tooling/planet-gen/biomes.toml b/tooling/planet-gen/biomes.toml new file mode 100644 index 000000000..9b085a08d --- /dev/null +++ b/tooling/planet-gen/biomes.toml @@ -0,0 +1,326 @@ +# Planet Generator — Biome & Color Configuration +# +# Single source of truth for biome classification, color palettes, +# and rendering parameters. Loaded by planet_simulation.py, +# render_heightmap.py, and planet_renderer.py. +# +# Edit this file to adjust colors without touching Python code. + +# ───────────────────────────────────────────────────────────────────── +# Whittaker biome classification table +# +# Temperature axis is ABSOLUTE KELVIN — not normalized per-world. +# Moisture axis is [0, 1]. +# Order matters: first match wins. +# ───────────────────────────────────────────────────────────────────── + +[[whittaker]] +id = 5 +name = "tropical_rainforest" +temp_lo = 303 +temp_hi = 999 +moist_lo = 0.65 +moist_hi = 1.00 + +[[whittaker]] +id = 6 +name = "tropical_seasonal" +temp_lo = 303 +temp_hi = 999 +moist_lo = 0.35 +moist_hi = 0.65 + +[[whittaker]] +id = 7 +name = "savanna" +temp_lo = 293 +temp_hi = 999 +moist_lo = 0.18 +moist_hi = 0.35 + +[[whittaker]] +id = 15 +name = "hot_desert" +temp_lo = 303 +temp_hi = 999 +moist_lo = 0.00 +moist_hi = 0.18 + +[[whittaker]] +id = 9 +name = "temperate_deciduous" +temp_lo = 283 +temp_hi = 308 +moist_lo = 0.55 +moist_hi = 1.00 + +[[whittaker]] +id = 8 +name = "temperate_grassland" +temp_lo = 278 +temp_hi = 303 +moist_lo = 0.30 +moist_hi = 0.55 + +[[whittaker]] +id = 10 +name = "temperate_rainforest" +temp_lo = 278 +temp_hi = 303 +moist_lo = 0.60 +moist_hi = 1.00 + +[[whittaker]] +id = 9 +name = "temperate_deciduous_cool" +temp_lo = 273 +temp_hi = 293 +moist_lo = 0.30 +moist_hi = 0.60 + +[[whittaker]] +id = 12 +name = "shrubland" +temp_lo = 278 +temp_hi = 303 +moist_lo = 0.10 +moist_hi = 0.30 + +[[whittaker]] +id = 14 +name = "subtropical_desert" +temp_lo = 283 +temp_hi = 308 +moist_lo = 0.00 +moist_hi = 0.18 + +[[whittaker]] +id = 13 +name = "temperate_desert" +temp_lo = 273 +temp_hi = 293 +moist_lo = 0.00 +moist_hi = 0.30 + +[[whittaker]] +id = 11 +name = "boreal_taiga" +temp_lo = 253 +temp_hi = 278 +moist_lo = 0.40 +moist_hi = 1.00 + +[[whittaker]] +id = 11 +name = "boreal_dry" +temp_lo = 253 +temp_hi = 278 +moist_lo = 0.15 +moist_hi = 0.40 + +[[whittaker]] +id = 16 +name = "tundra" +temp_lo = 243 +temp_hi = 263 +moist_lo = 0.00 +moist_hi = 1.00 + +[[whittaker]] +id = 16 +name = "cold_tundra" +temp_lo = 233 +temp_hi = 253 +moist_lo = 0.20 +moist_hi = 1.00 + +[[whittaker]] +id = 17 +name = "ice_snow" +temp_lo = 200 +temp_hi = 243 +moist_lo = 0.00 +moist_hi = 1.00 + +[[whittaker]] +id = 17 +name = "ice_cold_dry" +temp_lo = 243 +temp_hi = 273 +moist_lo = 0.00 +moist_hi = 0.15 + +# ───────────────────────────────────────────────────────────────────── +# Temperature bands per planet class (Kelvin) +# Fiction wins over physics — temperature is clamped to these bands. +# ───────────────────────────────────────────────────────────────────── + +[temperature_bands] +temperate = [275, 305] +oceanic = [278, 300] +forest = [275, 308] +arid = [295, 340] +frozen = [210, 265] +volcanic = [290, 380] +barren = [180, 380] + +# ───────────────────────────────────────────────────────────────────── +# Biome color palette +# +# Each biome class has two color modes: +# cartographic — map colors (National Geographic style, readable) +# photographic — orbital appearance (dark, muted, realistic) +# +# RGB values 0-255. +# ───────────────────────────────────────────────────────────────────── + +[biome_colors] + +# Ocean +0 = { name = "ocean_deep", cartographic = [ 80, 155, 190], photographic = [ 18, 45, 80] } +1 = { name = "ocean_mid", cartographic = [110, 185, 215], photographic = [ 28, 72, 115] } +2 = { name = "ocean_shallow", cartographic = [150, 210, 230], photographic = [ 42, 105, 145] } + +# Coast / lowland +3 = { name = "coast", cartographic = [155, 185, 130], photographic = [ 90, 108, 75] } +4 = { name = "lowland", cartographic = [120, 165, 100], photographic = [ 72, 98, 58] } + +# Tropical +5 = { name = "tropical_rainforest", cartographic = [ 50, 140, 65], photographic = [ 12, 38, 18] } +6 = { name = "tropical_seasonal", cartographic = [ 90, 170, 75], photographic = [ 28, 65, 28] } +7 = { name = "savanna", cartographic = [175, 210, 105], photographic = [108, 118, 55] } + +# Temperate +8 = { name = "temperate_grassland", cartographic = [190, 210, 110], photographic = [118, 128, 62] } +9 = { name = "temperate_deciduous", cartographic = [ 70, 148, 70], photographic = [ 22, 55, 28] } +10 = { name = "temperate_rainforest", cartographic = [ 45, 125, 65], photographic = [ 15, 45, 22] } +11 = { name = "boreal_taiga", cartographic = [ 28, 88, 55], photographic = [ 8, 30, 18] } +12 = { name = "shrubland", cartographic = [168, 168, 95], photographic = [ 98, 88, 55] } + +# Desert +13 = { name = "temperate_desert", cartographic = [215, 200, 155], photographic = [155, 138, 98] } +14 = { name = "subtropical_desert", cartographic = [210, 165, 85], photographic = [148, 108, 62] } +15 = { name = "hot_desert", cartographic = [215, 138, 55], photographic = [162, 98, 42] } + +# Cold +16 = { name = "tundra", cartographic = [198, 185, 145], photographic = [ 95, 88, 72] } +17 = { name = "ice_snow", cartographic = [235, 238, 242], photographic = [218, 228, 238] } +18 = { name = "mountain_rock", cartographic = [148, 135, 120], photographic = [ 88, 80, 72] } + +# Volcanic +19 = { name = "lava_field", cartographic = [ 55, 32, 22], photographic = [ 38, 22, 15] } + +# Exotic / extremophile +20 = { name = "chemosynthetic_mat", cartographic = [ 45, 88, 52], photographic = [ 18, 38, 22] } +21 = { name = "thermophilic_field", cartographic = [118, 72, 40], photographic = [ 78, 45, 22] } +22 = { name = "sulfuric_scrub", cartographic = [148, 130, 58], photographic = [ 98, 85, 35] } +23 = { name = "cryptobiotic_crust", cartographic = [130, 118, 100], photographic = [ 78, 70, 58] } +24 = { name = "lithic_pioneer", cartographic = [138, 125, 110], photographic = [ 82, 75, 65] } +25 = { name = "ash_field", cartographic = [ 68, 58, 52], photographic = [ 42, 35, 30] } +26 = { name = "ice_shelf", cartographic = [245, 246, 248], photographic = [235, 238, 242] } + +# Dry terrain (atmosphere gate — non-vegetated) +27 = { name = "dust_plain", cartographic = [195, 165, 115], photographic = [175, 142, 95] } +28 = { name = "rocky_highland", cartographic = [175, 145, 105], photographic = [152, 122, 85] } +29 = { name = "warm_dust", cartographic = [210, 175, 120], photographic = [188, 155, 105] } +30 = { name = "cold_rock", cartographic = [160, 140, 115], photographic = [135, 118, 95] } + +# Lunar terrain (grey rock) +31 = { name = "lunar_highland", cartographic = [165, 165, 162], photographic = [138, 138, 135] } +32 = { name = "lunar_mare", cartographic = [120, 120, 118], photographic = [100, 100, 98] } +33 = { name = "lunar_midland", cartographic = [145, 145, 142], photographic = [118, 118, 115] } + +# ───────────────────────────────────────────────────────────────────── +# Rendering colors (not biome-specific) +# ───────────────────────────────────────────────────────────────────── + +[render_colors] +river = [ 80, 140, 200] +coastline = [ 30, 45, 35] + +# ───────────────────────────────────────────────────────────────────── +# Star tints — subtle color shift from stellar type +# Applied multiplicatively to surface lighting. +# ───────────────────────────────────────────────────────────────────── + +[star_tints] +O = [0.90, 0.92, 1.00] +B = [0.94, 0.96, 1.00] +A = [0.97, 0.98, 1.00] +F = [1.00, 0.99, 0.97] +G = [1.00, 0.97, 0.93] +K = [1.00, 0.93, 0.85] +M = [1.00, 0.88, 0.78] + +# ───────────────────────────────────────────────────────────────────── +# Atmosphere rim colors per planet class (globe renderer) +# null = no atmosphere glow +# ───────────────────────────────────────────────────────────────────── + +[atmosphere_colors] +temperate = [0.45, 0.65, 1.00] +oceanic = [0.40, 0.60, 1.00] +forest = [0.42, 0.68, 0.80] +arid = [0.90, 0.72, 0.50] +martian = [0.82, 0.58, 0.40] +frozen = [0.75, 0.88, 1.00] +volcanic = [0.55, 0.40, 0.30] + +# ───────────────────────────────────────────────────────────────────── +# Gas giant band palettes (globe renderer) +# Each palette is a list of RGB band colors. +# ───────────────────────────────────────────────────────────────────── + +[gas_palettes] +jovian = [[0.78, 0.62, 0.44], [0.60, 0.44, 0.30], [0.88, 0.76, 0.60], + [0.72, 0.55, 0.38], [0.92, 0.84, 0.70], [0.55, 0.40, 0.28]] +neptunian = [[0.25, 0.45, 0.75], [0.35, 0.58, 0.85], [0.18, 0.35, 0.65], + [0.42, 0.65, 0.90], [0.20, 0.40, 0.70], [0.50, 0.70, 0.92]] +saturnian = [[0.82, 0.74, 0.55], [0.75, 0.66, 0.48], [0.88, 0.80, 0.62], + [0.70, 0.62, 0.44], [0.92, 0.86, 0.68], [0.65, 0.58, 0.42]] +icy = [[0.78, 0.85, 0.92], [0.70, 0.80, 0.90], [0.85, 0.90, 0.95], + [0.65, 0.75, 0.88], [0.90, 0.93, 0.97], [0.60, 0.70, 0.85]] +sulfuric = [[0.88, 0.80, 0.22], [0.80, 0.65, 0.18], [0.92, 0.86, 0.35], + [0.75, 0.60, 0.15], [0.85, 0.75, 0.28], [0.70, 0.55, 0.12]] +infernal = [[0.65, 0.18, 0.12], [0.45, 0.12, 0.08], [0.80, 0.25, 0.15], + [0.55, 0.15, 0.10], [0.72, 0.20, 0.12], [0.38, 0.10, 0.06]] + +# Locked selection list for deterministic random palette assignment. +# Order matters — changing this changes which body gets which palette. +# Only add to the end. Never reorder or remove. +selection_order = ["jovian", "neptunian", "saturnian", "icy", "sulfuric", "infernal"] + +# ───────────────────────────────────────────────────────────────────── +# Crater scaling factors +# atmosphere: fraction of impactors that survive entry +# tectonics: fraction of craters preserved (not resurfaced) +# Final crater count = base_count × atmo_factor × tect_factor +# ───────────────────────────────────────────────────────────────────── + +[crater_scaling] +base_count = 300 + +[crater_scaling.atmosphere] +none = 1.0 +thin = 0.6 +standard = 0.25 +thick = 0.08 + +[crater_scaling.tectonics] +none = 1.0 +low = 0.7 +active = 0.3 +extreme = 0.1 + +# ───────────────────────────────────────────────────────────────────── +# Exotic biome class IDs (modifier stack in compute_biome) +# ───────────────────────────────────────────────────────────────────── + +[exotic_classes] +chemosynthetic_mat = 20 +thermophilic_field = 21 +sulfuric_scrub = 22 +cryptobiotic_crust = 23 +ash_field = 25 +lava_field = 19 +ice_shelf = 26 diff --git a/tooling/planet-gen/body_definition_parser.py b/tooling/planet-gen/body_definition_parser.py new file mode 100644 index 000000000..58cb1c205 --- /dev/null +++ b/tooling/planet-gen/body_definition_parser.py @@ -0,0 +1,777 @@ +""" +body_definition_parser.py +------------------------- +Parses a system index.md file and produces one body_definition.json +per renderable celestial body. + +Input: index.md (system wiki page, bodies table + system profile) +Output: {body_id}_def.json per planet / moon / gas_giant + +Design principles: + - "rand" sentinel means: derive from seed + planet class constraints + - Explicit values in the bodies table or override dict always win + - Every derivation is documented so the logic is auditable + - No field is silently dropped — unknowns get a logged warning + +Field resolution order (highest wins): + 1. override dict (per-body, hand-authored for special cases like Sol) + 2. direct read (field exists verbatim in bodies table) + 3. derived (computed from other fields — documented formula) + 4. inferred (implied by combination of fields) + 5. randomised (seeded, within planet-class constraints) + +Usage: + python3 body_definition_parser.py path/to/index.md [--out-dir ./defs] + + # With overrides (e.g. Sol) + python3 body_definition_parser.py sol/index.md --overrides sol_overrides.json + +Override file format: + { + "GJ0g": { "rings": true, "ring_color": [0.88, 0.78, 0.55] }, + "GJ0f": { "rings": false }, + "GJ0d": { "orbit": { "axial_tilt_deg": 23.4 } } + } +""" + +import argparse +import hashlib +import json +import logging +import math +import os +import re +import sys +from pathlib import Path +from typing import Optional + +import numpy as np + +logging.basicConfig(level=logging.INFO, format=" %(levelname)s %(message)s") +log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants / lookup tables +# --------------------------------------------------------------------------- + +# Spectral type → solar luminosity (approximate) +STAR_LUMINOSITY = { + "O": 100000.0, "B": 1000.0, "A": 10.0, + "F": 2.5, "G": 1.0, "K": 0.4, "M": 0.04, +} + +# Spectral type → colour temperature K (approximate midpoint) +STAR_COLOUR_TEMP = { + "O": 40000, "B": 20000, "A": 9000, + "F": 7000, "G": 5800, "K": 4500, "M": 3200, +} + +# Star type → UV index category +STAR_UV = { + "O": "extreme", "B": "extreme", "A": "high", + "F": "high", "G": "moderate","K": "low", "M": "low", +} + +# atmosphere field → density string +ATMO_MAP = { + "none": "none", + "thin": "thin", + "breathable": "standard", + "dense": "thick", + "toxic": "thick", # Venus-style reducing atmosphere +} + +# hydrosphere → approximate land_fraction range [min, max] +HYDRO_LAND = { + "ocean": (0.28, 0.50), + "liquid_water":(0.35, 0.65), + "rivers": (0.50, 0.75), # Titan-style — surface liquid but mostly land + "ice": (0.70, 0.90), # mostly frozen land + "subsurface": (0.90, 0.99), # surface appears dry + "none": (0.97, 1.00), +} + +# biome → planet_class +BIOME_CLASS = { + "temperate": "temperate", + "arid": "arid", + "frozen": "frozen", + "volcanic": "volcanic", + "barren": "barren", + "forest": "forest", + "oceanic": "oceanic", +} + +# planet_class → axial tilt range [min, max] degrees +# Tidal locking check overrides this for short-period bodies +CLASS_TILT = { + "temperate": (10, 35), + "oceanic": (5, 25), + "forest": (10, 40), + "arid": (5, 30), + "frozen": (15, 60), # high tilt → seasonal extremes → frozen + "volcanic": (2, 20), + "barren": (0, 45), +} + +# planet_class → geothermal flux +CLASS_GEOTHERMAL = { + "volcanic": "extreme", + "temperate": "low", + "oceanic": "low", + "forest": "low", + "arid": "low", + "frozen": "low", + "barren": "low", +} + +# planet_class → polar ice latitude (fraction of 0–1, where 1 = poles) +# Lower = ice caps extend further toward equator +CLASS_POLAR_ICE = { + "temperate": (0.72, 0.85), + "oceanic": (0.80, 0.92), + "forest": (0.75, 0.88), + "arid": (0.90, 0.99), + "frozen": (0.10, 0.40), + "volcanic": (0.95, 1.00), + "barren": (0.92, 1.00), +} + +# planet_class → oblateness range +CLASS_OBLATENESS = { + "temperate": (0.001, 0.005), + "oceanic": (0.001, 0.004), + "forest": (0.001, 0.005), + "arid": (0.001, 0.004), + "frozen": (0.001, 0.003), + "volcanic": (0.002, 0.008), + "barren": (0.000, 0.003), +} + +# Gas giant band palettes available +from biome_config import GAS_PALETTE_SELECTION as GAS_PALETTES + +# planet_class → cloud coverage base range +CLASS_CLOUD = { + "temperate": (0.35, 0.55), + "oceanic": (0.55, 0.75), + "forest": (0.40, 0.60), + "arid": (0.05, 0.20), + "frozen": (0.20, 0.45), + "volcanic": (0.60, 0.85), + "barren": (0.00, 0.05), +} + +# Atmosphere classes that allow clouds +CLOUD_CAPABLE = {"standard", "thick", "thin"} + +# Render defaults +RENDER_DEFAULTS = { + "globe_light_angle_deg": 125, + "specular_ocean": True, + "night_side_ambient": 0.025, +} + +# Ring probability for gas giants (if not overridden) +RING_PROBABILITY = 0.40 # 40% chance of rings — Saturn is special + +# Ring colour palettes paired to band palettes +RING_COLOURS = { + "jovian": [0.55, 0.48, 0.35], # faint dark rings + "neptunian": [0.72, 0.82, 0.95], # blue-tinted + "saturnian": [0.88, 0.78, 0.55], # warm golden + "icy": [0.85, 0.90, 0.95], # pale ice + "sulfuric": [0.75, 0.70, 0.30], # sulphur-tinted +} + +# --------------------------------------------------------------------------- +# Seeded RNG helpers +# --------------------------------------------------------------------------- + +def _seed_from_id(body_id: str) -> int: + """Deterministic integer seed from body ID string.""" + h = hashlib.md5(body_id.encode()).digest() + return int.from_bytes(h[:4], "little") + + +def _rng(body_id: str, salt: str = "") -> np.random.Generator: + """Seeded RNG for a specific body + context. Always reproducible.""" + seed = _seed_from_id(body_id + salt) + return np.random.default_rng(seed) + + +def _rand_range(body_id: str, lo: float, hi: float, salt: str = "") -> float: + """Uniform float in [lo, hi], seeded from body_id.""" + return float(_rng(body_id, salt).uniform(lo, hi)) + + +def _rand_choice(body_id: str, choices: list, salt: str = "") -> object: + """Random choice from list, seeded from body_id.""" + idx = int(_rng(body_id, salt).integers(0, len(choices))) + return choices[idx] + + +def _rand_bool(body_id: str, probability: float, salt: str = "") -> bool: + """True with given probability, seeded from body_id.""" + return float(_rng(body_id, salt).uniform(0, 1)) < probability + + +# --------------------------------------------------------------------------- +# Orbital mechanics +# --------------------------------------------------------------------------- + +def _derive_distance_au(period_days: float, star_type: str) -> float: + """ + Kepler's third law: a³ = P² × M_star + Returns orbital distance in AU. + M_star approximated from spectral type luminosity (L ∝ M^4 for main seq). + """ + if period_days <= 0: + return 1.0 + lum = STAR_LUMINOSITY.get(star_type, 1.0) + m_star = lum ** 0.25 # rough mass from luminosity + p_years = period_days / 365.25 + return (p_years ** 2 * m_star) ** (1.0 / 3.0) + + +def _check_habitability(body_def: dict) -> None: + """ + Warn if a temperate/oceanic/forest world has a physically implausible + equilibrium temperature. Helps catch orbital distance errors early. + """ + pclass = body_def.get("planet_class", "") + if pclass not in ("temperate", "oceanic", "forest"): + return + lum = body_def["star"].get("luminosity_solar", 1.0) + dist = body_def["orbit"].get("distance_au", 1.0) + atmo = body_def["physical"].get("atmosphere", "standard") + gh = {"none": 0, "thin": 8, "standard": 33, "thick": 80}.get(atmo, 33) + t_eq = 278.5 * (lum ** 0.25) / math.sqrt(max(dist, 0.01)) + gh + if t_eq > 340: + log.warning(f" {body_def['id']}: T_eq={t_eq:.0f}K ({t_eq-273:.0f}°C) — " + f"too hot for {pclass}. Check distance_au ({dist:.2f} AU). " + f"Habitable zone ≈ {(278.5*(lum**0.25)/(290-gh))**2:.2f} AU") + elif t_eq < 220: + log.warning(f" {body_def['id']}: T_eq={t_eq:.0f}K ({t_eq-273:.0f}°C) — " + f"too cold for {pclass}. Check distance_au ({dist:.2f} AU).") + + +def _is_tidally_locked(period_days: float, star_type: str) -> bool: + """ + Bodies with very short periods around dim stars are likely tidally locked. + Rough threshold: period < 20 days for M-stars, < 10 for K-stars. + """ + thresholds = {"M": 20, "K": 10, "F": 4, "G": 4, "A": 2, "B": 1, "O": 1} + return period_days < thresholds.get(star_type, 5) + + +def _tidal_heating(period_days: float, mass_class: str, parent_is_giant: bool) -> str: + """ + Estimate geothermal flux modifier from tidal heating. + Short-period moons around gas giants get significant heating (Io/Europa). + """ + if not parent_is_giant: + return "low" + if period_days < 3: + return "extreme" # Io-like + if period_days < 10: + return "moderate" # Europa-like + return "low" + + +# --------------------------------------------------------------------------- +# Markdown parser — bodies table +# --------------------------------------------------------------------------- + +def _parse_star(system_profile_text: str) -> dict: + """ + Extract star type and luminosity from system profile section. + Looks for lines like: | **Star** | G2V · 0.0 ly | + """ + match = re.search(r'\*\*Star\*\*.*?([OBAFGKM])\d*[Vab]*', system_profile_text) + star_type = match.group(1) if match else "G" + return { + "type": star_type, + "luminosity_solar": STAR_LUMINOSITY.get(star_type, 1.0), + "color_temp_K": STAR_COLOUR_TEMP.get(star_type, 5800), + } + + +def _parse_bodies_table(md_text: str) -> list[dict]: + """ + Parse the Celestial Bodies table from the markdown. + Returns list of raw row dicts. + """ + # Find the table section + table_match = re.search( + r'\| Orbit \| ID.*?\n(\|[-| ]+\|\n)(.*?)(?=\n##|\Z)', + md_text, re.DOTALL + ) + if not table_match: + log.warning("No bodies table found in markdown") + return [] + + table_body = table_match.group(2) + rows = [] + + for line in table_body.strip().splitlines(): + if not line.strip().startswith('|'): + continue + cells = [c.strip() for c in line.split('|')[1:-1]] + if len(cells) < 10: + continue + + # Extract body ID from backtick notation + id_match = re.search(r'`([^`]+)`', cells[1]) + if not id_match: + continue + body_id = id_match.group(1) + + # Skip non-body rows + body_type = cells[3].strip().lower() + if body_type in ('asteroid_belt', 'oort_cloud', ''): + continue + if body_type not in ('planet', 'moon', 'gas_giant'): + continue + + def cell(i, default="—"): + v = cells[i].strip() if i < len(cells) else default + return v if v not in ('—', '', '-') else default + + # Gravity: strip 'g' suffix + grav_str = cell(7) + try: + gravity = float(re.sub(r'[^\d.]', '', grav_str)) + except (ValueError, TypeError): + gravity = None + + # Orbit period + try: + period = float(cell(8)) + except (ValueError, TypeError): + period = 0.0 + + # Day length + try: + day_h = float(cell(9)) + except (ValueError, TypeError): + day_h = None + + # Parent body — detect from ↳ prefix + is_moon_row = '↳' in cells[0] + + rows.append({ + "orbit_label": cells[0].strip(), + "body_id": body_id, + "name": cell(2) if cell(2) != '—' else None, + "body_type": body_type, + "inhabited": cell(4).lower() == 'yes', + "population": cell(5), + "mass_class": cell(6).lower(), # terrestrial / dwarf / gas_giant / ice_giant + "gravity_g": gravity, + "period_days": period, + "day_h": day_h, + "atmosphere": cell(10).lower(), + "biome": cell(11).lower(), + "hydrosphere": cell(12).lower(), + "economy": cell(13), + "settlement": cell(14), + "industrial": cell(15), + "is_moon_row": is_moon_row, + }) + + return rows + + +# --------------------------------------------------------------------------- +# Body definition builder +# --------------------------------------------------------------------------- + +def _build_body_def( + row: dict, + star: dict, + system_id: str, + overrides: dict, + parent_is_giant: bool = False, +) -> Optional[dict]: + """ + Convert one bodies table row into a body_definition dict. + overrides: per-body override dict (keyed by body_id). + Returns None for bodies that don't need a render (asteroid belts etc). + """ + bid = row["body_id"] + btype = row["body_type"] + mass = row["mass_class"] + biome = row["biome"] + hydro = row["hydrosphere"] + atmo = row["atmosphere"] + period = row["period_days"] + gravity = row["gravity_g"] + star_type = star["type"] + + ov = overrides.get(bid, {}) # per-body override dict + + # ── Planet class ────────────────────────────────────────────────────── + if btype == "gas_giant" or mass in ("gas_giant", "ice_giant"): + planet_class = "gas_giant" + else: + planet_class = BIOME_CLASS.get(biome, "barren") + + planet_class = ov.get("planet_class", planet_class) + + # ── Body scale ──────────────────────────────────────────────────────── + body_scale = "moon" if row["is_moon_row"] or mass == "dwarf" else "planet" + body_scale = ov.get("body_scale", body_scale) + + # ── Seed — deterministic from body ID ───────────────────────────────── + seed = _seed_from_id(bid) + seed = ov.get("seed", seed) + + # ── Orbital distance ────────────────────────────────────────────────── + distance_au = _derive_distance_au(period, star_type) + + # ── Axial tilt ──────────────────────────────────────────────────────── + tilt_ov = (ov.get("orbit", {}) or {}).get("axial_tilt_deg", "rand") + if tilt_ov != "rand": + axial_tilt = float(tilt_ov) + elif _is_tidally_locked(period, star_type) and not parent_is_giant: + axial_tilt = _rand_range(bid, 0, 5, "tilt") + elif planet_class in CLASS_TILT: + lo, hi = CLASS_TILT[planet_class] + axial_tilt = _rand_range(bid, lo, hi, "tilt") + else: + axial_tilt = _rand_range(bid, 5, 35, "tilt") + + # ── Atmosphere density ──────────────────────────────────────────────── + atmo_density = ATMO_MAP.get(atmo, "none") + atmo_density = ov.get("atmosphere_density", atmo_density) + + # ── Atmosphere colour — from star type + planet class ───────────────── + atmo_colors = { + "temperate": [0.45, 0.65, 1.00], + "oceanic": [0.40, 0.60, 1.00], + "forest": [0.42, 0.68, 0.80], + "arid": [0.90, 0.72, 0.50], + "frozen": [0.75, 0.88, 1.00], + "volcanic": [0.55, 0.40, 0.30], + "barren": None, + } + atmo_color = atmo_colors.get(planet_class) + atmo_color = ov.get("atmosphere_color", atmo_color) + + # ── Land fraction ───────────────────────────────────────────────────── + land_ov = (ov.get("terrain", {}) or {}).get("land_fraction", "rand") + if land_ov != "rand": + land_fraction = float(land_ov) + else: + lo, hi = HYDRO_LAND.get(hydro, (0.90, 0.99)) + land_fraction = _rand_range(bid, lo, hi, "land") + + # ── Polar ice latitude ──────────────────────────────────────────────── + ice_ov = (ov.get("terrain", {}) or {}).get("polar_ice_lat", "rand") + if ice_ov != "rand": + polar_ice_lat = float(ice_ov) + else: + lo, hi = CLASS_POLAR_ICE.get(planet_class, (0.80, 0.95)) + # High axial tilt → ice caps extend further toward equator + tilt_factor = (axial_tilt / 90.0) * 0.3 + lo = max(0.05, lo - tilt_factor) + hi = max(0.10, hi - tilt_factor) + polar_ice_lat = _rand_range(bid, lo, hi, "ice") + + # ── Tectonics ───────────────────────────────────────────────────────── + tectonic_map = { + "volcanic": "extreme", "temperate": "active", + "oceanic": "active", "forest": "active", + "arid": "low", "frozen": "low", "barren": "none", + } + tectonics = tectonic_map.get(planet_class, "low") + tectonics = ov.get("tectonics", tectonics) + + # ── Geothermal flux ─────────────────────────────────────────────────── + geothermal = CLASS_GEOTHERMAL.get(planet_class, "low") + # Tidal heating for moons of gas giants + if parent_is_giant: + tidal = _tidal_heating(period, mass, parent_is_giant) + if tidal != "low": + geothermal = tidal + geothermal = ov.get("geothermal_flux", geothermal) + + # ── UV index ────────────────────────────────────────────────────────── + uv_index = STAR_UV.get(star_type, "moderate") + # Thin/no atmosphere → UV reaches surface directly + if atmo_density in ("none", "thin"): + uv_map = {"low": "moderate", "moderate": "high", "high": "extreme"} + uv_index = uv_map.get(uv_index, uv_index) + uv_index = ov.get("uv_index", uv_index) + + # ── Substrate ───────────────────────────────────────────────────────── + substrate_map = { + "volcanic": "sulfuric", + "arid": "silicate", + "frozen": "ice", + "barren": "silicate", + "temperate":"silicate", + "oceanic": "silicate", + "forest": "silicate", + } + substrate = substrate_map.get(planet_class, "silicate") + if hydro == "subsurface" and planet_class == "frozen": + substrate = "ice" + substrate = ov.get("substrate", substrate) + + # ── Chemosynthetic modifier ─────────────────────────────────────────── + # Europa case: frozen + subsurface + tidal heating → chemosynthetic + chemosynthetic = False + if hydro == "subsurface" and geothermal in ("moderate", "high", "extreme"): + chemosynthetic = True + chemosynthetic = ov.get("chemosynthetic", chemosynthetic) + + # ── Oblateness ──────────────────────────────────────────────────────── + oblat_lo, oblat_hi = CLASS_OBLATENESS.get(planet_class, (0.001, 0.005)) + oblateness = _rand_range(bid, oblat_lo, oblat_hi, "oblat") + if btype == "gas_giant" or mass in ("gas_giant", "ice_giant"): + oblateness = _rand_range(bid, 0.050, 0.090, "oblat") + oblateness = ov.get("oblateness", oblateness) + + # ── Clouds ──────────────────────────────────────────────────────────── + clouds_enabled = atmo_density in CLOUD_CAPABLE and planet_class != "barren" + if planet_class == "barren": + clouds_enabled = False + cld_ov = ov.get("clouds", {}) or {} + clouds_enabled = cld_ov.get("enabled", clouds_enabled) + + coverage_ov = cld_ov.get("coverage_base", "rand") + if coverage_ov != "rand": + coverage = float(coverage_ov) + else: + lo, hi = CLASS_CLOUD.get(planet_class, (0.10, 0.40)) + coverage = _rand_range(bid, lo, hi, "cloud") + + # ── Gas giant specific ──────────────────────────────────────────────── + gas_giant_cfg = None + rings_cfg = None + + if planet_class == "gas_giant": + palette_ov = (ov.get("gas_giant", {}) or {}).get("band_palette", "rand") + if palette_ov == "rand": + palette = _rand_choice(bid, GAS_PALETTES, "palette") + else: + palette = palette_ov + + storm_count = int(_rand_range(bid, 1, 5, "storms")) + storm_count = (ov.get("gas_giant", {}) or {}).get("storm_count", storm_count) + storm_size = _rand_range(bid, 0.06, 0.14, "storm_sz") + storm_size = (ov.get("gas_giant", {}) or {}).get("storm_max_size", storm_size) + + gas_giant_cfg = { + "band_palette": palette, + "storm_count": storm_count, + "storm_max_size": round(float(storm_size), 3), + } + + # Rings + rings_ov = ov.get("rings", "rand") + if rings_ov == "rand": + has_rings = _rand_bool(bid, RING_PROBABILITY, "rings") + elif isinstance(rings_ov, dict): + has_rings = rings_ov.get("enabled", True) + else: + has_rings = bool(rings_ov) + + if has_rings: + planet_class = "gas_giant_ringed" + r_inner = round(_rand_range(bid, 1.08, 1.25, "r_inner"), 2) + r_outer = round(_rand_range(bid, 2.20, 2.80, "r_outer"), 2) + opacity = round(_rand_range(bid, 0.45, 0.72, "r_opa"), 2) + rcolor = RING_COLOURS.get(palette, [0.75, 0.70, 0.60]) + + # Merge any explicit ring overrides + if isinstance(rings_ov, dict): + r_inner = rings_ov.get("inner_radius_factor", r_inner) + r_outer = rings_ov.get("outer_radius_factor", r_outer) + opacity = rings_ov.get("opacity_base", opacity) + rcolor = rings_ov.get("ring_color", rcolor) + + rings_cfg = { + "enabled": True, + "inner_radius_factor": r_inner, + "outer_radius_factor": r_outer, + "opacity_base": opacity, + "ring_color": rcolor, + } + + # ── Render config ───────────────────────────────────────────────────── + render_cfg = dict(RENDER_DEFAULTS) + render_cfg["specular_ocean"] = hydro in ("ocean", "liquid_water", "rivers") + if planet_class in ("barren", "arid", "volcanic"): + render_cfg["specular_ocean"] = False + render_cfg.update(ov.get("render", {})) + + # ── Assemble ────────────────────────────────────────────────────────── + body_def = { + "id": bid, + "name": row["name"], + "body_type": btype, + "planet_class": planet_class, + "body_scale": body_scale, + "seed": seed, + + "star": star, + + "orbit": { + "distance_au": round(distance_au, 3), + "period_days": period, + "axial_tilt_deg": round(axial_tilt, 1), + }, + + "physical": { + "gravity_g": gravity, + "oblateness": round(oblateness, 4), + "atmosphere": atmo_density, + "atmosphere_color": atmo_color, + }, + + "terrain": { + "land_fraction": round(land_fraction, 3), + "polar_ice_lat": round(polar_ice_lat, 3), + "tectonics": tectonics, + }, + + "environment": { + "geothermal_flux": geothermal, + "uv_index": uv_index, + "substrate": substrate, + "chemosynthetic": chemosynthetic, + "hydrosphere": hydro, + }, + + "clouds": { + "enabled": bool(clouds_enabled), + "coverage_base": round(coverage, 3), + }, + + "render": render_cfg, + } + + # Gas giant extras + if gas_giant_cfg: + body_def["gas_giant"] = gas_giant_cfg + if rings_cfg: + body_def["rings"] = rings_cfg + + # Wiki cultural data — not used by the generator, carried for the + # body index.md template and downstream pipelines. + pop_raw = row.get("population", "—") + body_def["wiki"] = { + "inhabited": row.get("inhabited", False), + "population": pop_raw if pop_raw not in ("—", "", None) else None, + "economy": row.get("economy") if row.get("economy") not in ("—", "", None) else None, + "settlement": row.get("settlement") if row.get("settlement") not in ("—", "", None) else None, + "industrial": row.get("industrial") if row.get("industrial") not in ("—", "", None) else None, + } + + return body_def + + +# --------------------------------------------------------------------------- +# System parser — top-level entry +# --------------------------------------------------------------------------- + +def parse_system( + md_path: str, + overrides: dict = None, + out_dir: str = None, +) -> list[dict]: + """ + Parse a system index.md and return list of body_definition dicts. + Optionally write one JSON file per body into out_dir. + + overrides: { body_id: { field: value, ... } } + """ + overrides = overrides or {} + md_text = Path(md_path).read_text(encoding="utf-8") + + # Extract system ID from first header + sys_match = re.search(r'\*\*([A-Z0-9 ]+)\*\*', md_text) + system_id = sys_match.group(1).replace(" ", "_") if sys_match else "UNKNOWN" + + # Parse star + star = _parse_star(md_text) + log.info(f"System: {system_id} Star: {star['type']}-type " + f"L={star['luminosity_solar']:.3g} Lsun") + + # Parse bodies table + rows = _parse_bodies_table(md_text) + log.info(f"Found {len(rows)} renderable bodies") + + # Track which bodies are moons of gas giants (for tidal heating) + # Simple heuristic: if the previous non-moon row was a gas_giant, this is its moon + last_giant = False + body_defs = [] + + for row in rows: + bid = row["body_id"] + btype = row["body_type"] + mass = row["mass_class"] + + is_giant = btype == "gas_giant" or mass in ("gas_giant", "ice_giant") + + # Determine if this moon orbits a gas giant + parent_is_giant = row["is_moon_row"] and last_giant + + if not row["is_moon_row"]: + last_giant = is_giant + + # Build definition + body_def = _build_body_def( + row, star, system_id, overrides, + parent_is_giant=parent_is_giant, + ) + if body_def is None: + continue + + body_defs.append(body_def) + log.info(f" {bid:20s} {body_def['planet_class']:20s} " + f"scale={body_def['body_scale']:6s} " + f"seed={body_def['seed']}") + + # Write output files + if out_dir: + os.makedirs(out_dir, exist_ok=True) + for bd in body_defs: + out_path = os.path.join(out_dir, f"{bd['id']}_def.json") + with open(out_path, "w") as f: + json.dump(bd, f, indent=2) + log.info(f"Wrote {len(body_defs)} body definitions → {out_dir}/") + + _check_habitability(body_def) + return body_defs + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Parse system index.md → body_definition.json files" + ) + parser.add_argument("md_file", help="Path to system index.md") + parser.add_argument("--out-dir", default="./body_defs", + help="Output directory for JSON files (default: ./body_defs)") + parser.add_argument("--overrides", default=None, + help="Path to JSON overrides file (optional)") + parser.add_argument("--print", action="store_true", + help="Print all body definitions to stdout") + args = parser.parse_args() + + overrides = {} + if args.overrides: + with open(args.overrides) as f: + overrides = json.load(f) + + defs = parse_system(args.md_file, overrides=overrides, out_dir=args.out_dir) + + if args.print: + print(json.dumps(defs, indent=2)) diff --git a/tooling/planet-gen/generate b/tooling/planet-gen/generate new file mode 100755 index 000000000..f183b9098 --- /dev/null +++ b/tooling/planet-gen/generate @@ -0,0 +1,5 @@ +#!/bin/bash +# Planet generator CLI wrapper. +# Usage: tooling/planet-gen/generate body_def.json --output-dir ./output +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec python3 "$SCRIPT_DIR/generate.py" "$@" diff --git a/tooling/planet-gen/generate.py b/tooling/planet-gen/generate.py new file mode 100644 index 000000000..a618143ef --- /dev/null +++ b/tooling/planet-gen/generate.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +""" +Planet Generator — CLI entry point. + +Two input modes: + 1. Body definition JSON: generate body_def.json --output-dir ./output + 2. System index.md: generate --system wiki/star-systems/GJ-144/index.md + +Outputs per body into {output-dir}/{body_id}/: + heightmap.png — clean equirectangular cartographic map (no chrome) + globe.png — 512×512 sphere render + body.json — body descriptor + rendering metadata + terrain.npz — compressed terrain grids for downstream generators + rivers.json — river polylines in grid coords + +Optional (spike/review only): + heightmap_chrome.png — heightmap with title bar + legend overlay +""" + +import argparse +import json +import os +import sys +import time + +# Venv bootstrap — re-exec into .venv/bin/python if not already there. +from pathlib import Path +TOOLING_DIR = Path(__file__).resolve().parent +WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve() +_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python" +if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve(): + os.execv(str(_venv_python), [str(_venv_python)] + sys.argv) + +import numpy as np + +from planet_simulation import simulate +from render_heightmap import render_heightmap + + +def _build_markers(body_def: dict, terrain: dict) -> dict: + """Extract geographic markers from terrain data.""" + from scipy.ndimage import label, center_of_mass + + grid_h = terrain["_grid_h"] + grid_w = terrain["_grid_w"] + sea_level = terrain["sea_level"] + elevation = terrain["elevation"] + surface_water = terrain["surface_water"] + biome = terrain["biome"] + + markers = { + "grid": {"w": grid_w, "h": grid_h}, + "rivers": [], + "oceans": [], + "mountain_ranges": [], + "roads": [], + "cities": [], + "railroads": [], + "pois": [], + } + + # ── Rivers ─────────────────────────────────────────────────────────── + for i, path in enumerate(terrain.get("rivers", [])): + markers["rivers"].append({ + "id": f"river_{i}", + "name": None, # named by copy team or procedural namer + "path": path, + }) + + # ── Oceans / seas ──────────────────────────────────────────────────── + # Label connected water bodies and record their center + area + if surface_water.any(): + water_labels, n_bodies = label(surface_water) + total_cells = grid_h * grid_w + for lbl in range(1, n_bodies + 1): + mask = water_labels == lbl + area_cells = int(mask.sum()) + area_frac = area_cells / total_cells + if area_frac < 0.005: + continue # skip tiny puddles + cy, cx = center_of_mass(mask) + kind = "ocean" if area_frac > 0.10 else "sea" if area_frac > 0.02 else "lake" + markers["oceans"].append({ + "id": f"water_{lbl}", + "name": None, + "kind": kind, + "center": [int(cy), int(cx)], + "area_fraction": round(area_frac, 4), + }) + + # ── Mountain ranges ────────────────────────────────────────────────── + # High-elevation connected regions on land + land = ~surface_water + elev_norm = np.where(land, + (elevation - sea_level) / (1.0 - sea_level + 1e-9), + 0.0) + mountains = land & (elev_norm > 0.55) + if mountains.any(): + mtn_labels, n_ranges = label(mountains) + for lbl in range(1, n_ranges + 1): + mask = mtn_labels == lbl + area = int(mask.sum()) + if area < 20: + continue # skip tiny peaks + cy, cx = center_of_mass(mask) + # Find the ridge line: cells with highest elevation in the range + ys, xs = np.where(mask) + peak_idx = np.argmax(elevation[ys, xs]) + markers["mountain_ranges"].append({ + "id": f"range_{lbl}", + "name": None, + "center": [int(cy), int(cx)], + "peak": [int(ys[peak_idx]), int(xs[peak_idx])], + "area_cells": area, + }) + + return markers + + +def _generate_body(body_def: dict, hmap_w: int, hmap_h: int, + globe_size: int, render_mode: str, output_dir: str, + chrome: bool = False): + """Generate all outputs for a single body definition.""" + body_id = body_def["id"] + if body_def.pop("_flat_output", False): + body_dir = output_dir # output directly, no body_id subdir + else: + body_dir = os.path.join(output_dir, body_id) + os.makedirs(body_dir, exist_ok=True) + + planet_class = body_def.get("planet_class", "unknown") + name = body_def.get("name") or body_id + + print(f"\n {body_id} ({name}) — {planet_class}") + + t0 = time.time() + + # ── 1. Simulate ────────────────────────────────────────────────────── + terrain = simulate(body_def) + is_gas = not terrain + t_sim = time.time() + + if is_gas: + print(f" simulate: gas giant ({t_sim - t0:.1f}s)") + else: + print(f" simulate: {t_sim - t0:.1f}s " + f"sea={terrain['sea_level']:.3f} " + f"land={int((~terrain['surface_water']).sum())} " + f"rivers={len(terrain['rivers'])}") + + # ── 2. Render heightmap ────────────────────────────────────────────── + t_hmap = t_sim + if not is_gas: + import render_heightmap as rh + rh.OUT_W = hmap_w + rh.OUT_H = hmap_h + rh.UI_SCALE = hmap_w / 1024 + rh.RENDER_MODE = render_mode + rh.BIOME_RGB = rh._build_biome_rgb(render_mode) + rh.OCEAN_DEEP, rh.OCEAN_MID, rh.OCEAN_SHALLOW = rh._ocean_arrays(render_mode) + + # Clean heightmap (no title/legend) + hmap_img = render_heightmap(body_def, terrain, chrome=False) + hmap_img.save(os.path.join(body_dir, "heightmap.png")) + + # Chrome version for review (optional — saved to /tmp, not shipped) + if chrome: + hmap_chrome = render_heightmap(body_def, terrain, chrome=True) + chrome_path = f"/tmp/{body_id}_heightmap_chrome.png" + hmap_chrome.save(chrome_path) + print(f" chrome: {chrome_path}") + + t_hmap = time.time() + print(f" heightmap: {t_hmap - t_sim:.1f}s {hmap_w}×{hmap_h}") + + # ── 3. Render globe ────────────────────────────────────────────────── + try: + from planet_renderer import render_globe + globe_img = render_globe(body_def, terrain, size=globe_size) + globe_img.save(os.path.join(body_dir, "globe.png")) + t_globe = time.time() + print(f" globe: {t_globe - t_hmap:.1f}s {globe_size}×{globe_size}") + except Exception as e: + print(f" globe: FAILED — {e}") + t_globe = time.time() + + # ── 4. Write data files ────────────────────────────────────────────── + # Body definition lives in the index.md frontmatter — no body.json needed. + + if not is_gas: + # terrain.npz — grids for downstream generators + save_dict = {} + for key in ("elevation", "temperature", "moisture", "hillshade", + "biome", "surface_water", "river_grid"): + if key in terrain: + save_dict[key] = terrain[key] + save_dict["sea_level"] = np.array([terrain["sea_level"]]) + np.savez_compressed(os.path.join(body_dir, "terrain.npz"), **save_dict) + + # markers.json — named geographic and cultural features. + markers = _build_markers(body_def, terrain) + with open(os.path.join(body_dir, "markers.json"), "w") as f: + json.dump(markers, f, indent=2) + + elapsed = time.time() - t0 + print(f" total: {elapsed:.1f}s → {body_dir}/") + + +def main(): + parser = argparse.ArgumentParser( + description="Planet generator — heightmap + globe from body definitions") + + # Input modes + parser.add_argument("body_def", nargs="?", + help="Path to body definition JSON file") + parser.add_argument("--system", + help="Path to system index.md — generates all bodies") + parser.add_argument("--overrides", + help="Path to per-body overrides JSON (used with --system)") + + # Output + parser.add_argument("--output-dir", default=".", + help="Root output directory (bodies get subdirs)") + + # Rendering + parser.add_argument("--heightmap-size", default="4096x2048", + help="Heightmap output resolution (WxH)") + parser.add_argument("--globe-size", type=int, default=512, + help="Globe output resolution (square, locked at 512)") + parser.add_argument("--render-mode", choices=["cartographic", "photographic"], + default="cartographic") + parser.add_argument("--chrome", action="store_true", + help="Also render heightmap with title/legend (review only, not shipped)") + + args = parser.parse_args() + + # Parse heightmap size + try: + hw, hh = args.heightmap_size.lower().split("x") + hmap_w, hmap_h = int(hw), int(hh) + except ValueError: + print(f"error: invalid heightmap size '{args.heightmap_size}'", file=sys.stderr) + sys.exit(1) + + # ── Collect body definitions ───────────────────────────────────────── + body_defs = [] + + if args.system: + # Read from system index.md → parse bodies table + from body_definition_parser import parse_system + overrides = {} + if args.overrides: + with open(args.overrides) as f: + overrides = json.load(f) + body_defs = parse_system(args.system, overrides=overrides) + print(f"System: {args.system} — {len(body_defs)} bodies") + elif args.body_def: + input_path = args.body_def + if input_path.endswith(".json"): + with open(input_path) as f: + body_defs = [json.load(f)] + elif input_path.endswith(".md"): + # Read body definition from frontmatter + import yaml + with open(input_path) as f: + content = f.read() + if content.startswith("---"): + fm_end = content.index("---", 3) + fm = yaml.safe_load(content[3:fm_end]) + if "id" in fm and "planet_class" in fm: + body_defs = [fm] + fm["_flat_output"] = True # no body_id subdir + # Output alongside the body index.md if no --output-dir + if args.output_dir == ".": + args.output_dir = str(Path(input_path).parent) + else: + print(f"error: {input_path} frontmatter missing 'id' or 'planet_class'", + file=sys.stderr) + sys.exit(1) + else: + print(f"error: {input_path} has no YAML frontmatter", file=sys.stderr) + sys.exit(1) + else: + print(f"error: unrecognized input format: {input_path}", file=sys.stderr) + sys.exit(1) + else: + parser.error("Provide a body_def (.json or .md) or --system index.md") + + # ── Generate ───────────────────────────────────────────────────────── + t_total = time.time() + for bd in body_defs: + _generate_body(bd, hmap_w, hmap_h, args.globe_size, + args.render_mode, args.output_dir, chrome=args.chrome) + + elapsed = time.time() - t_total + print(f"\n All done: {len(body_defs)} bodies in {elapsed:.1f}s") + + +if __name__ == "__main__": + main() diff --git a/tooling/planet-gen/planet_renderer.py b/tooling/planet-gen/planet_renderer.py new file mode 100644 index 000000000..6a20c8504 --- /dev/null +++ b/tooling/planet-gen/planet_renderer.py @@ -0,0 +1,970 @@ +""" +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.") diff --git a/tooling/planet-gen/planet_simulation.py b/tooling/planet-gen/planet_simulation.py new file mode 100644 index 000000000..0862c23ef --- /dev/null +++ b/tooling/planet-gen/planet_simulation.py @@ -0,0 +1,956 @@ +""" +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}/") diff --git a/tooling/planet-gen/render_heightmap.py b/tooling/planet-gen/render_heightmap.py new file mode 100644 index 000000000..7380d5cdd --- /dev/null +++ b/tooling/planet-gen/render_heightmap.py @@ -0,0 +1,486 @@ +""" +render_heightmap.py +------------------- +Renders a 4096×2048 annotated equirectangular heightmap PNG from a terrain dict. + +This is the PRIMARY output of the planet generator pipeline. +The globe render is a separate downstream step that reads the same terrain dict. + +Equirectangular projection: + X axis: longitude 0°→360° (left to right) + Y axis: latitude +90°→-90° (top to bottom, north pole at row 0) + +Each terrain grid cell maps to a block of output pixels via bicubic upscale. +All rendering is in float32; final conversion to uint8 at save time. + +Output layers (composited in order): + 1. Biome colour — smooth-blended from Whittaker grid, not hard-snapped + 2. Elevation shading — subtle darkening in valleys, lightening on peaks + 3. Hillshade — surface normal lighting pass (makes terrain 3D-readable) + 4. Coastline — 1px dark border at sea level threshold + 5. Rivers — anti-aliased polylines from river list + 6. Lat/lon grid — every 30°, semi-transparent + 7. Title panel — body metadata strip at top + 8. Legend — biome colour swatches at bottom + +Geographic only. No settlements, roads, or cultural data. +Those live in a separate JSON sidecar and are overlaid by the atlas app. + +Usage: + from render_heightmap import render_heightmap + from planet_simulation import simulate + from body_definition_parser import parse_system + + defs = parse_system("index.md") + terrain = simulate(defs[0]) + img = render_heightmap(defs[0], terrain) + img.save("GJ144d_heightmap.png") +""" + +import math +import numpy as np +from PIL import Image, ImageDraw, ImageFilter, ImageFont +from scipy.ndimage import gaussian_filter, binary_dilation + +from biome_config import ( + BIOME_PALETTE as _BIOME_PALETTE_CFG, + RIVER_RGB as _RIVER_RGB_CFG, + COAST_RGB as _COAST_RGB_CFG, + MAX_BIOME_ID, + build_biome_rgb, +) + +# --------------------------------------------------------------------------- +# Output resolution +# --------------------------------------------------------------------------- + +OUT_W = 4096 +OUT_H = 2048 +UI_SCALE = OUT_W / 1024 # 4.0 — all pixel sizes scale with this + +# --------------------------------------------------------------------------- +# Biome colour palette +# Indices match planet_simulation.WHITTAKER_TABLE class IDs. +# Extended exotic classes appended at end. +# --------------------------------------------------------------------------- + +# Biome palette loaded from biomes.toml via biome_config. +# Per-planet overrides can patch _BIOME_PALETTE_CFG before rendering. +BIOME_PALETTE = _BIOME_PALETTE_CFG + +def _build_biome_rgb(mode: str = "cartographic") -> dict: + return build_biome_rgb(mode) + +RENDER_MODE = "cartographic" +BIOME_RGB = _build_biome_rgb(RENDER_MODE) + +def _ocean_arrays(mode: str = "cartographic"): + return ( + np.array(BIOME_PALETTE[0][mode], dtype=np.float32), + np.array(BIOME_PALETTE[1][mode], dtype=np.float32), + np.array(BIOME_PALETTE[2][mode], dtype=np.float32), + ) + +OCEAN_DEEP, OCEAN_MID, OCEAN_SHALLOW = _ocean_arrays(RENDER_MODE) + +RIVER_RGB = _RIVER_RGB_CFG +COAST_RGB = _COAST_RGB_CFG + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _upscale(grid: np.ndarray, order: int = 1) -> np.ndarray: + """ + Upscale a (GRID_H, GRID_W) float32 grid to (OUT_H, OUT_W). + order=1 → bilinear (smooth, good for continuous fields) + order=0 → nearest (sharp, good for integer class grids) + """ + from scipy.ndimage import zoom + zy = OUT_H / grid.shape[0] + zx = OUT_W / grid.shape[1] + return zoom(grid.astype(np.float32), (zy, zx), order=order).astype(np.float32) + + +def _upscale_int(grid: np.ndarray) -> np.ndarray: + """Nearest-neighbour upscale for integer class grids (biome, etc).""" + from scipy.ndimage import zoom + zy = OUT_H / grid.shape[0] + zx = OUT_W / grid.shape[1] + return zoom(grid.astype(np.int32), (zy, zx), order=0).astype(np.int8) + + +# --------------------------------------------------------------------------- +# Layer 1 + 2 + 3: Biome colour + elevation shading + hillshade +# --------------------------------------------------------------------------- + +def _render_surface(terrain: dict) -> np.ndarray: + """ + Returns (OUT_H, OUT_W, 3) float32 RGB in [0, 1]. + + Compositing order: + biome_colour × elevation_shade × hillshade_factor + """ + elevation = _upscale(terrain["elevation"], order=1) + hillshade = _upscale(terrain["hillshade"], order=1) + biome_up = _upscale_int(terrain["biome"]) + surf_water = _upscale(terrain["surface_water"].astype(np.float32), + order=0) > 0.5 + sea_level = terrain["sea_level"] + + H, W = elevation.shape + + # ── Biome base colour ───────────────────────────────────────────────── + # Clamp biome index, look up palette + # Build lookup array from active BIOME_RGB dict for vectorised indexing + max_id = max(BIOME_RGB.keys()) + pal_arr = np.zeros((max_id + 1, 3), dtype=np.float32) + for k, v in BIOME_RGB.items(): + pal_arr[k] = v + biome_clamped = np.clip(biome_up, 0, max_id) + rgb = pal_arr[biome_clamped].astype(np.float32) / 255.0 + + # ── Ocean depth blending ─────────────────────────────────────────────── + # Override flat ocean biome with smooth depth gradient + if surf_water.any(): + depth = np.clip((sea_level - elevation) / (sea_level + 1e-9), 0, 1) + deep_col = OCEAN_DEEP / 255.0 + mid_col = OCEAN_MID / 255.0 + shallow_col = OCEAN_SHALLOW / 255.0 + + # Three-stop blend: 0=shallow, 0.5=mid, 1=deep + t1 = np.clip(depth * 2.0, 0, 1) # 0→0.5 depth: shallow→mid + t2 = np.clip((depth - 0.5) * 2.0, 0, 1) # 0.5→1 depth: mid→deep + ocean_rgb = (shallow_col * (1 - t1)[..., None] + + mid_col * (t1 * (1 - t2))[..., None] + + deep_col * t2[..., None]) + rgb = np.where(surf_water[..., None], ocean_rgb, rgb) + + # ── Elevation shading on land ────────────────────────────────────────── + # Slight darkening in lowlands, brightening on ridges + elev_norm = np.where( + ~surf_water, + np.clip((elevation - sea_level) / (1.0 - sea_level + 1e-9), 0, 1), + 0.0) + elev_shade = 0.88 + 0.18 * elev_norm # [0.88, 1.06] — clamp below + rgb = np.where(~surf_water[..., None], + np.clip(rgb * elev_shade[..., None], 0, 1), + rgb) + + # ── Hillshade ────────────────────────────────────────────────────────── + # Apply only on land — ocean gets its own depth shading + # Blend factor: 0.55 hillshade + 0.45 flat (keeps colours readable) + hs_blend = 0.55 * hillshade + 0.45 + rgb = np.where(~surf_water[..., None], + np.clip(rgb * hs_blend[..., None], 0, 1), + rgb) + + return rgb.astype(np.float32) + + +# --------------------------------------------------------------------------- +# Layer 4: Coastline +# --------------------------------------------------------------------------- + +def _render_coastline(terrain: dict, + rgb: np.ndarray) -> np.ndarray: + """Draw a 1–2px dark border at the sea level threshold.""" + surf_water = _upscale(terrain["surface_water"].astype(np.float32), + order=0) > 0.5 + + # Dilate water mask by 1px, XOR with original → coastline ring + dilated = binary_dilation(surf_water, iterations=2) + coastline = dilated & ~surf_water + + coast_col = np.array(COAST_RGB, dtype=np.float32) / 255.0 + out = rgb.copy() + out[coastline] = coast_col + return out + + +# --------------------------------------------------------------------------- +# Layer 5: Rivers +# --------------------------------------------------------------------------- + +def _render_rivers(terrain: dict, + rgb: np.ndarray) -> np.ndarray: + """ + Draw rivers as anti-aliased polylines. + River list is in simulation grid coords (row, col) at GRID_H×GRID_W. + Scale to output pixels, draw with PIL. + """ + rivers = terrain.get("rivers", []) + if not rivers: + return rgb + + GRID_H, GRID_W = terrain["_grid_h"], terrain["_grid_w"] + scale_y = OUT_H / GRID_H + scale_x = OUT_W / GRID_W + + # Work on a PIL image for anti-aliased line drawing + img = Image.fromarray((rgb * 255).clip(0, 255).astype(np.uint8), mode="RGB") + draw = ImageDraw.Draw(img) + + river_col = RIVER_RGB + + for path in rivers: + if len(path) < 2: + continue + # Scale grid coords to output pixels + pts = [(int(c * scale_x), int(r * scale_y)) for r, c in path] + # Line width scales with path length — longer rivers are wider. + # Base width doubled for readability at high output resolutions. + width = max(2, min(6, len(path) // 40)) + draw.line(pts, fill=river_col, width=width, joint="curve") + + return np.array(img).astype(np.float32) / 255.0 + + +# --------------------------------------------------------------------------- +# Layer 6: Lat/lon grid +# --------------------------------------------------------------------------- + +def _render_grid(rgb: np.ndarray) -> np.ndarray: + """Draw lat/lon lines every 30° as semi-transparent overlays.""" + out = rgb.copy() + col = np.array([255, 255, 255], dtype=np.float32) / 255.0 + alpha = 0.12 # very subtle + + # Latitude lines (horizontal) every 30°: at 1/6, 2/6, 3/6, 4/6, 5/6 of height + for frac in [1/6, 2/6, 3/6, 4/6, 5/6]: + y = int(frac * OUT_H) + y0 = max(0, y - 1); y1 = min(OUT_H - 1, y + 1) + out[y0:y1, :] = out[y0:y1, :] * (1 - alpha) + col * alpha + + # Longitude lines (vertical) every 30° + for frac in [1/6, 2/6, 3/6, 4/6, 5/6]: + x = int(frac * OUT_W) + x0 = max(0, x - 1); x1 = min(OUT_W - 1, x + 1) + out[:, x0:x1] = out[:, x0:x1] * (1 - alpha) + col * alpha + + return out + + +# --------------------------------------------------------------------------- +# Layer 7: Title panel +# --------------------------------------------------------------------------- + +def _load_font(size: int): + try: + return ImageFont.load_default(size=size) + except TypeError: + return ImageFont.load_default() + + +def _render_title(img: Image.Image, body_def: dict) -> Image.Image: + """Draw metadata strip at top of image.""" + panel_h = int(52 * UI_SCALE) + panel = Image.new("RGBA", (OUT_W, panel_h), (12, 15, 22, 210)) + + img_rgba = img.convert("RGBA") + img_rgba.paste(panel, (0, 0), panel) + img_out = img_rgba.convert("RGB") + draw = ImageDraw.Draw(img_out) + + name = body_def.get("name") or body_def.get("id", "Unknown") + bid = body_def.get("id", "") + pclass = body_def.get("planet_class", "").replace("_ringed", "") + star = body_def.get("star", {}) + orbit = body_def.get("orbit", {}) + phys = body_def.get("physical", {}) + env = body_def.get("environment", {}) + + star_str = f"{star.get('type','?')}-type" + dist_str = f"{orbit.get('distance_au', 0):.2f} AU" + grav_str = f"{phys.get('gravity_g', '?')}g" + atmo_str = phys.get("atmosphere", "?") + hydro_str = env.get("hydrosphere", "?") + + px = int(14 * UI_SCALE) + py = int(7 * UI_SCALE) + lh = int(17 * UI_SCALE) + + title_col = (200, 210, 228) + sub_col = (130, 145, 168) + dim_col = (75, 88, 110) + + line1 = f"{name.upper()} · {bid} · {pclass}" + line2 = f"{star_str} · {dist_str} · {grav_str} · atmo: {atmo_str} · hydro: {hydro_str}" + line3 = "HEIGHTMAP · Settled Reach" + + draw.text((px, py), line1, fill=title_col, font=_load_font(int(14 * UI_SCALE))) + draw.text((px, py + lh), line2, fill=sub_col, font=_load_font(int(12 * UI_SCALE))) + draw.text((px, py + lh*2), line3, fill=dim_col, font=_load_font(int(11 * UI_SCALE))) + + return img_out + + +# --------------------------------------------------------------------------- +# Layer 8: Legend +# --------------------------------------------------------------------------- + +def _biome_legend_items(terrain: dict) -> list: + """ + Return list of (label, RGB) for biome classes actually present + in this terrain — no phantom legend entries. + """ + biome = terrain["biome"] + present = set(np.unique(biome).tolist()) + + LABELS = { + 0: "ocean deep", 1: "ocean", 2: "coastal water", + 3: "coast", 5: "rainforest", 6: "trop. forest", + 7: "savanna", 8: "grassland", 9: "forest", + 10: "rainforest", 11: "boreal", 12: "shrubland", + 13: "temperate desert", 14: "desert", 15: "hot desert", + 16: "tundra", 17: "ice / snow", 18: "mountain rock", + 19: "lava field", 20: "chemosyn. mat", 21: "thermophilic", + 22: "sulfuric scrub", 23: "crypto. crust", 25: "ash field", + } + + items = [] + # Fixed display order — most common first, exotic last + order = [0, 1, 2, 3, 7, 8, 5, 6, 9, 10, 11, 12, 13, 14, 15, 16, 17, + 18, 19, 20, 21, 22, 23, 25] + for cls_id in order: + if cls_id in present and cls_id in LABELS: + rgb = BIOME_RGB.get(cls_id, (128, 128, 128)) + items.append((LABELS[cls_id], rgb)) + + # Always include river swatch if rivers exist + if terrain.get("rivers"): + items.append(("river", RIVER_RGB)) + + return items + + +def _render_legend(img: Image.Image, terrain: dict) -> Image.Image: + """Draw biome legend strip at bottom of image.""" + items = _biome_legend_items(terrain) + if not items: + return img + + draw = ImageDraw.Draw(img) + sw = int(14 * UI_SCALE) # swatch width + sh = int(12 * UI_SCALE) # swatch height + pad_x = int(14 * UI_SCALE) + leg_y = OUT_H - int(34 * UI_SCALE) + font = _load_font(int(10 * UI_SCALE)) + gap = int(6 * UI_SCALE) + step = int(108 * UI_SCALE) + + lx = pad_x + for label, rgb in items: + if lx + step > OUT_W - pad_x: + break + draw.rectangle([(lx, leg_y), (lx + sw, leg_y + sh)], fill=rgb) + draw.text((lx + sw + gap, leg_y), label, + fill=(185, 192, 205), font=font) + lx += step + + return img + + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- + +def render_heightmap(body_def: dict, + terrain: dict, + out_w: int = OUT_W, + out_h: int = OUT_H, + render_mode: str = "cartographic", + chrome: bool = True) -> Image.Image: + """ + Render a 4096×2048 annotated equirectangular heightmap PNG. + + Parameters + ---------- + body_def : dict — body definition from body_definition_parser + terrain : dict — terrain dict from planet_simulation.simulate() + out_w, out_h — output resolution (default 4096×2048) + + Returns + ------- + PIL.Image.Image RGB + """ + global OUT_W, OUT_H, UI_SCALE + OUT_W = out_w + OUT_H = out_h + UI_SCALE = out_w / 1024 + + # Set active colour mode for this render + global BIOME_RGB, OCEAN_DEEP, OCEAN_MID, OCEAN_SHALLOW, RENDER_MODE + RENDER_MODE = render_mode + BIOME_RGB = _build_biome_rgb(render_mode) + OCEAN_DEEP, OCEAN_MID, OCEAN_SHALLOW = _ocean_arrays(render_mode) + + # Guard: require simulation data + required = ("elevation", "biome", "surface_water", "hillshade", "sea_level") + missing = [k for k in required if k not in terrain] + if missing: + raise ValueError(f"terrain dict missing keys: {missing}") + + # 1+2+3: surface colour with elevation shading and hillshade + rgb = _render_surface(terrain) + + # 4: coastline + rgb = _render_coastline(terrain, rgb) + + # 5: rivers + rgb = _render_rivers(terrain, rgb) + + # 6: lat/lon grid + rgb = _render_grid(rgb) + + # Convert to PIL for text rendering + img = Image.fromarray( + (rgb * 255).clip(0, 255).astype(np.uint8), mode="RGB") + + if chrome: + # 7: title panel + img = _render_title(img, body_def) + # 8: legend + img = _render_legend(img, terrain) + + return img + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + import sys, json, time, os + + if len(sys.argv) < 2: + print("Usage: python3 render_heightmap.py body_def.json [--small]") + sys.exit(1) + + with open(sys.argv[1]) as f: + bd = json.load(f) + + # --small flag renders at 1024×512 for fast iteration + small = "--small" in sys.argv + w, h = (1024, 512) if small else (OUT_W, OUT_H) + + from planet_simulation import simulate + + print(f"Simulating: {bd['id']} ({bd['planet_class']})") + t0 = time.time() + terrain = simulate(bd) + sim_t = time.time() - t0 + + if not terrain: + print("Gas giant — no heightmap.") + sys.exit(0) + + print(f"Rendering heightmap {w}×{h}…") + t1 = time.time() + img = render_heightmap(bd, terrain, out_w=w, out_h=h) + ren_t = time.time() - t1 + + out = f"/mnt/user-data/outputs/{bd['id']}_heightmap.png" + img.save(out, format="PNG") + print(f"Saved: {out}") + print(f" simulate={sim_t:.1f}s render={ren_t:.1f}s total={sim_t+ren_t:.1f}s") diff --git a/tooling/planet-gen/scaffold_bodies.py b/tooling/planet-gen/scaffold_bodies.py new file mode 100644 index 000000000..c4251939e --- /dev/null +++ b/tooling/planet-gen/scaffold_bodies.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +Scaffold per-body index.md files from a system index.md. + +Reads the system's Celestial Bodies table, runs body_definition_parser +to produce full body definitions, and writes one index.md per body +under wiki/star-systems/{system}/bodies/{body_id}/index.md. + +The frontmatter IS the body definition — the generator reads it directly. +Below the frontmatter is space for authored body content (narrative, notes). + +Usage: + python3 scaffold_bodies.py wiki/star-systems/GJ-144/index.md + python3 scaffold_bodies.py wiki/star-systems/GJ-144/index.md --overrides sol_overrides.json + python3 scaffold_bodies.py wiki/star-systems/GJ-144/index.md --dry-run + +Only creates files that don't exist yet — never overwrites authored content. +Re-running is safe: existing body index.md files are skipped. +""" + +import argparse +import json +import os +import sys +from pathlib import Path + +# Venv bootstrap +TOOLING_DIR = Path(__file__).resolve().parent +WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve() +_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python" +if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve(): + os.execv(str(_venv_python), [str(_venv_python)] + sys.argv) + +try: + import yaml +except ImportError: + # PyYAML is in pyproject.toml deps + print("error: PyYAML not installed — run `make setup-venv`", file=sys.stderr) + sys.exit(1) + +from body_definition_parser import parse_system + + +def _body_to_frontmatter(bd: dict) -> str: + """Convert a body definition dict to clean YAML frontmatter.""" + # Order fields for readability + ordered = {} + for key in ("id", "name", "body_type", "planet_class", "body_scale", "seed"): + if key in bd: + ordered[key] = bd[key] + for section in ("star", "orbit", "physical", "terrain", "environment", + "clouds", "render", "gas_giant", "rings"): + if section in bd and bd[section] is not None: + ordered[section] = bd[section] + + return yaml.dump(ordered, default_flow_style=False, sort_keys=False, + allow_unicode=True).rstrip() + + +def _body_prose(bd: dict, system_dir: Path) -> str: + """Generate markdown content below the frontmatter.""" + name = bd.get("name") or bd.get("id") + bid = bd["id"] + pclass = bd.get("planet_class", "unknown").replace("_ringed", "") + btype = bd.get("body_type", "planet") + wiki = bd.get("wiki", {}) + phys = bd.get("physical", {}) + orbit = bd.get("orbit", {}) + terrain = bd.get("terrain", {}) + env = bd.get("environment", {}) + + # System link (relative path from body dir to system index) + system_link = "../../index.md" + + lines = [f"# {name}", ""] + + # Type line + if btype == "moon": + lines.append(f"{pclass.title()} moon.") + elif pclass in ("gas_giant", "gas_giant_ringed"): + lines.append(f"Gas giant.") + else: + lines.append(f"{pclass.title()} {btype}.") + lines.append("") + + # System link + lines.append(f"**System:** [{system_dir.name}]({system_link})") + lines.append("") + + # Visual overview + lines.append("## Visual") + lines.append("") + lines.append(f"![Globe](globe.png)") + lines.append("") + if pclass not in ("gas_giant",): + lines.append(f"![Heightmap](heightmap.png)") + lines.append("") + + # Profile table + lines.append("## Profile") + lines.append("") + is_gas = pclass in ("gas_giant",) + + lines.append("| | |") + lines.append("|---|---|") + lines.append(f"| **Type** | {btype} |") + lines.append(f"| **Class** | {pclass} |") + if phys.get("gravity_g") and not is_gas: + lines.append(f"| **Gravity** | {phys['gravity_g']}g |") + if phys.get("atmosphere") and phys["atmosphere"] != "none": + lines.append(f"| **Atmosphere** | {phys['atmosphere']} |") + hydro = env.get("hydrosphere") + if hydro and hydro not in ("none", "—", ""): + lines.append(f"| **Hydrosphere** | {hydro} |") + if not is_gas and terrain.get("land_fraction") is not None: + lines.append(f"| **Land** | {terrain['land_fraction']*100:.0f}% |") + if orbit.get("period_days"): + label = "Orbit" if btype == "moon" else "Year" + lines.append(f"| **{label}** | {orbit['period_days']:.0f} days |") + if wiki.get("inhabited"): + lines.append(f"| **Inhabited** | yes |") + if wiki.get("population"): + lines.append(f"| **Population** | {wiki['population']} |") + if wiki.get("economy"): + lines.append(f"| **Economy** | {wiki['economy']} |") + if wiki.get("settlement"): + lines.append(f"| **Settlement** | {wiki['settlement']} |") + if wiki.get("industrial"): + lines.append(f"| **Industry** | {wiki['industrial']} |") + lines.append("") + + # Content section + lines.append("## Description") + lines.append("") + lines.append("") + lines.append("") + + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser( + description="Scaffold per-body index.md files from a system index.md") + parser.add_argument("system_index", help="Path to system index.md") + parser.add_argument("--overrides", help="Per-body overrides JSON") + parser.add_argument("--dry-run", action="store_true", + help="Print what would be created without writing") + args = parser.parse_args() + + system_path = Path(args.system_index) + system_dir = system_path.parent + bodies_dir = system_dir / "bodies" + + overrides = {} + if args.overrides: + with open(args.overrides) as f: + overrides = json.load(f) + + body_defs = parse_system(str(system_path), overrides=overrides) + print(f"System: {system_path} — {len(body_defs)} renderable bodies") + + created = 0 + skipped = 0 + for bd in body_defs: + body_id = bd["id"] + body_dir = bodies_dir / body_id + index_path = body_dir / "index.md" + + if index_path.exists(): + print(f" skip {body_id} — index.md exists") + skipped += 1 + continue + + frontmatter = _body_to_frontmatter(bd) + prose = _body_prose(bd, system_dir) + content = f"---\n{frontmatter}\n---\n\n{prose}" + + if args.dry_run: + print(f" would create {index_path}") + print(f" {bd.get('planet_class', '?')} / {bd.get('body_type', '?')}") + else: + body_dir.mkdir(parents=True, exist_ok=True) + with open(index_path, "w") as f: + f.write(content) + print(f" created {index_path}") + + created += 1 + + action = "would create" if args.dry_run else "created" + print(f"\n {action} {created}, skipped {skipped}") + + +if __name__ == "__main__": + main()