feat(assets): planet generator pipeline — heightmap + globe from wiki data

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 15:54:15 +02:00
co-authored by Claude Opus 4.6
parent f968ee23f1
commit f84275edf3
11 changed files with 4682 additions and 0 deletions
+5
View File
@@ -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" "$@"
+564
View File
@@ -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()
+99
View File
@@ -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"]),
}
+326
View File
@@ -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
@@ -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 01, 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))
+5
View File
@@ -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" "$@"
+300
View File
@@ -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()
+970
View File
@@ -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.")
+956
View File
@@ -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}/")
+486
View File
@@ -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 12px 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")
+194
View File
@@ -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("<!-- Body content: narrative, history, notes -->")
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()