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:
Executable
+5
@@ -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" "$@"
|
||||
@@ -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()
|
||||
@@ -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"]),
|
||||
}
|
||||
@@ -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 0–1, where 1 = poles)
|
||||
# Lower = ice caps extend further toward equator
|
||||
CLASS_POLAR_ICE = {
|
||||
"temperate": (0.72, 0.85),
|
||||
"oceanic": (0.80, 0.92),
|
||||
"forest": (0.75, 0.88),
|
||||
"arid": (0.90, 0.99),
|
||||
"frozen": (0.10, 0.40),
|
||||
"volcanic": (0.95, 1.00),
|
||||
"barren": (0.92, 1.00),
|
||||
}
|
||||
|
||||
# planet_class → oblateness range
|
||||
CLASS_OBLATENESS = {
|
||||
"temperate": (0.001, 0.005),
|
||||
"oceanic": (0.001, 0.004),
|
||||
"forest": (0.001, 0.005),
|
||||
"arid": (0.001, 0.004),
|
||||
"frozen": (0.001, 0.003),
|
||||
"volcanic": (0.002, 0.008),
|
||||
"barren": (0.000, 0.003),
|
||||
}
|
||||
|
||||
# Gas giant band palettes available
|
||||
from biome_config import GAS_PALETTE_SELECTION as GAS_PALETTES
|
||||
|
||||
# planet_class → cloud coverage base range
|
||||
CLASS_CLOUD = {
|
||||
"temperate": (0.35, 0.55),
|
||||
"oceanic": (0.55, 0.75),
|
||||
"forest": (0.40, 0.60),
|
||||
"arid": (0.05, 0.20),
|
||||
"frozen": (0.20, 0.45),
|
||||
"volcanic": (0.60, 0.85),
|
||||
"barren": (0.00, 0.05),
|
||||
}
|
||||
|
||||
# Atmosphere classes that allow clouds
|
||||
CLOUD_CAPABLE = {"standard", "thick", "thin"}
|
||||
|
||||
# Render defaults
|
||||
RENDER_DEFAULTS = {
|
||||
"globe_light_angle_deg": 125,
|
||||
"specular_ocean": True,
|
||||
"night_side_ambient": 0.025,
|
||||
}
|
||||
|
||||
# Ring probability for gas giants (if not overridden)
|
||||
RING_PROBABILITY = 0.40 # 40% chance of rings — Saturn is special
|
||||
|
||||
# Ring colour palettes paired to band palettes
|
||||
RING_COLOURS = {
|
||||
"jovian": [0.55, 0.48, 0.35], # faint dark rings
|
||||
"neptunian": [0.72, 0.82, 0.95], # blue-tinted
|
||||
"saturnian": [0.88, 0.78, 0.55], # warm golden
|
||||
"icy": [0.85, 0.90, 0.95], # pale ice
|
||||
"sulfuric": [0.75, 0.70, 0.30], # sulphur-tinted
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seeded RNG helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _seed_from_id(body_id: str) -> int:
|
||||
"""Deterministic integer seed from body ID string."""
|
||||
h = hashlib.md5(body_id.encode()).digest()
|
||||
return int.from_bytes(h[:4], "little")
|
||||
|
||||
|
||||
def _rng(body_id: str, salt: str = "") -> np.random.Generator:
|
||||
"""Seeded RNG for a specific body + context. Always reproducible."""
|
||||
seed = _seed_from_id(body_id + salt)
|
||||
return np.random.default_rng(seed)
|
||||
|
||||
|
||||
def _rand_range(body_id: str, lo: float, hi: float, salt: str = "") -> float:
|
||||
"""Uniform float in [lo, hi], seeded from body_id."""
|
||||
return float(_rng(body_id, salt).uniform(lo, hi))
|
||||
|
||||
|
||||
def _rand_choice(body_id: str, choices: list, salt: str = "") -> object:
|
||||
"""Random choice from list, seeded from body_id."""
|
||||
idx = int(_rng(body_id, salt).integers(0, len(choices)))
|
||||
return choices[idx]
|
||||
|
||||
|
||||
def _rand_bool(body_id: str, probability: float, salt: str = "") -> bool:
|
||||
"""True with given probability, seeded from body_id."""
|
||||
return float(_rng(body_id, salt).uniform(0, 1)) < probability
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orbital mechanics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _derive_distance_au(period_days: float, star_type: str) -> float:
|
||||
"""
|
||||
Kepler's third law: a³ = P² × M_star
|
||||
Returns orbital distance in AU.
|
||||
M_star approximated from spectral type luminosity (L ∝ M^4 for main seq).
|
||||
"""
|
||||
if period_days <= 0:
|
||||
return 1.0
|
||||
lum = STAR_LUMINOSITY.get(star_type, 1.0)
|
||||
m_star = lum ** 0.25 # rough mass from luminosity
|
||||
p_years = period_days / 365.25
|
||||
return (p_years ** 2 * m_star) ** (1.0 / 3.0)
|
||||
|
||||
|
||||
def _check_habitability(body_def: dict) -> None:
|
||||
"""
|
||||
Warn if a temperate/oceanic/forest world has a physically implausible
|
||||
equilibrium temperature. Helps catch orbital distance errors early.
|
||||
"""
|
||||
pclass = body_def.get("planet_class", "")
|
||||
if pclass not in ("temperate", "oceanic", "forest"):
|
||||
return
|
||||
lum = body_def["star"].get("luminosity_solar", 1.0)
|
||||
dist = body_def["orbit"].get("distance_au", 1.0)
|
||||
atmo = body_def["physical"].get("atmosphere", "standard")
|
||||
gh = {"none": 0, "thin": 8, "standard": 33, "thick": 80}.get(atmo, 33)
|
||||
t_eq = 278.5 * (lum ** 0.25) / math.sqrt(max(dist, 0.01)) + gh
|
||||
if t_eq > 340:
|
||||
log.warning(f" {body_def['id']}: T_eq={t_eq:.0f}K ({t_eq-273:.0f}°C) — "
|
||||
f"too hot for {pclass}. Check distance_au ({dist:.2f} AU). "
|
||||
f"Habitable zone ≈ {(278.5*(lum**0.25)/(290-gh))**2:.2f} AU")
|
||||
elif t_eq < 220:
|
||||
log.warning(f" {body_def['id']}: T_eq={t_eq:.0f}K ({t_eq-273:.0f}°C) — "
|
||||
f"too cold for {pclass}. Check distance_au ({dist:.2f} AU).")
|
||||
|
||||
|
||||
def _is_tidally_locked(period_days: float, star_type: str) -> bool:
|
||||
"""
|
||||
Bodies with very short periods around dim stars are likely tidally locked.
|
||||
Rough threshold: period < 20 days for M-stars, < 10 for K-stars.
|
||||
"""
|
||||
thresholds = {"M": 20, "K": 10, "F": 4, "G": 4, "A": 2, "B": 1, "O": 1}
|
||||
return period_days < thresholds.get(star_type, 5)
|
||||
|
||||
|
||||
def _tidal_heating(period_days: float, mass_class: str, parent_is_giant: bool) -> str:
|
||||
"""
|
||||
Estimate geothermal flux modifier from tidal heating.
|
||||
Short-period moons around gas giants get significant heating (Io/Europa).
|
||||
"""
|
||||
if not parent_is_giant:
|
||||
return "low"
|
||||
if period_days < 3:
|
||||
return "extreme" # Io-like
|
||||
if period_days < 10:
|
||||
return "moderate" # Europa-like
|
||||
return "low"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown parser — bodies table
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_star(system_profile_text: str) -> dict:
|
||||
"""
|
||||
Extract star type and luminosity from system profile section.
|
||||
Looks for lines like: | **Star** | G2V · 0.0 ly |
|
||||
"""
|
||||
match = re.search(r'\*\*Star\*\*.*?([OBAFGKM])\d*[Vab]*', system_profile_text)
|
||||
star_type = match.group(1) if match else "G"
|
||||
return {
|
||||
"type": star_type,
|
||||
"luminosity_solar": STAR_LUMINOSITY.get(star_type, 1.0),
|
||||
"color_temp_K": STAR_COLOUR_TEMP.get(star_type, 5800),
|
||||
}
|
||||
|
||||
|
||||
def _parse_bodies_table(md_text: str) -> list[dict]:
|
||||
"""
|
||||
Parse the Celestial Bodies table from the markdown.
|
||||
Returns list of raw row dicts.
|
||||
"""
|
||||
# Find the table section
|
||||
table_match = re.search(
|
||||
r'\| Orbit \| ID.*?\n(\|[-| ]+\|\n)(.*?)(?=\n##|\Z)',
|
||||
md_text, re.DOTALL
|
||||
)
|
||||
if not table_match:
|
||||
log.warning("No bodies table found in markdown")
|
||||
return []
|
||||
|
||||
table_body = table_match.group(2)
|
||||
rows = []
|
||||
|
||||
for line in table_body.strip().splitlines():
|
||||
if not line.strip().startswith('|'):
|
||||
continue
|
||||
cells = [c.strip() for c in line.split('|')[1:-1]]
|
||||
if len(cells) < 10:
|
||||
continue
|
||||
|
||||
# Extract body ID from backtick notation
|
||||
id_match = re.search(r'`([^`]+)`', cells[1])
|
||||
if not id_match:
|
||||
continue
|
||||
body_id = id_match.group(1)
|
||||
|
||||
# Skip non-body rows
|
||||
body_type = cells[3].strip().lower()
|
||||
if body_type in ('asteroid_belt', 'oort_cloud', ''):
|
||||
continue
|
||||
if body_type not in ('planet', 'moon', 'gas_giant'):
|
||||
continue
|
||||
|
||||
def cell(i, default="—"):
|
||||
v = cells[i].strip() if i < len(cells) else default
|
||||
return v if v not in ('—', '', '-') else default
|
||||
|
||||
# Gravity: strip 'g' suffix
|
||||
grav_str = cell(7)
|
||||
try:
|
||||
gravity = float(re.sub(r'[^\d.]', '', grav_str))
|
||||
except (ValueError, TypeError):
|
||||
gravity = None
|
||||
|
||||
# Orbit period
|
||||
try:
|
||||
period = float(cell(8))
|
||||
except (ValueError, TypeError):
|
||||
period = 0.0
|
||||
|
||||
# Day length
|
||||
try:
|
||||
day_h = float(cell(9))
|
||||
except (ValueError, TypeError):
|
||||
day_h = None
|
||||
|
||||
# Parent body — detect from ↳ prefix
|
||||
is_moon_row = '↳' in cells[0]
|
||||
|
||||
rows.append({
|
||||
"orbit_label": cells[0].strip(),
|
||||
"body_id": body_id,
|
||||
"name": cell(2) if cell(2) != '—' else None,
|
||||
"body_type": body_type,
|
||||
"inhabited": cell(4).lower() == 'yes',
|
||||
"population": cell(5),
|
||||
"mass_class": cell(6).lower(), # terrestrial / dwarf / gas_giant / ice_giant
|
||||
"gravity_g": gravity,
|
||||
"period_days": period,
|
||||
"day_h": day_h,
|
||||
"atmosphere": cell(10).lower(),
|
||||
"biome": cell(11).lower(),
|
||||
"hydrosphere": cell(12).lower(),
|
||||
"economy": cell(13),
|
||||
"settlement": cell(14),
|
||||
"industrial": cell(15),
|
||||
"is_moon_row": is_moon_row,
|
||||
})
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Body definition builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_body_def(
|
||||
row: dict,
|
||||
star: dict,
|
||||
system_id: str,
|
||||
overrides: dict,
|
||||
parent_is_giant: bool = False,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Convert one bodies table row into a body_definition dict.
|
||||
overrides: per-body override dict (keyed by body_id).
|
||||
Returns None for bodies that don't need a render (asteroid belts etc).
|
||||
"""
|
||||
bid = row["body_id"]
|
||||
btype = row["body_type"]
|
||||
mass = row["mass_class"]
|
||||
biome = row["biome"]
|
||||
hydro = row["hydrosphere"]
|
||||
atmo = row["atmosphere"]
|
||||
period = row["period_days"]
|
||||
gravity = row["gravity_g"]
|
||||
star_type = star["type"]
|
||||
|
||||
ov = overrides.get(bid, {}) # per-body override dict
|
||||
|
||||
# ── Planet class ──────────────────────────────────────────────────────
|
||||
if btype == "gas_giant" or mass in ("gas_giant", "ice_giant"):
|
||||
planet_class = "gas_giant"
|
||||
else:
|
||||
planet_class = BIOME_CLASS.get(biome, "barren")
|
||||
|
||||
planet_class = ov.get("planet_class", planet_class)
|
||||
|
||||
# ── Body scale ────────────────────────────────────────────────────────
|
||||
body_scale = "moon" if row["is_moon_row"] or mass == "dwarf" else "planet"
|
||||
body_scale = ov.get("body_scale", body_scale)
|
||||
|
||||
# ── Seed — deterministic from body ID ─────────────────────────────────
|
||||
seed = _seed_from_id(bid)
|
||||
seed = ov.get("seed", seed)
|
||||
|
||||
# ── Orbital distance ──────────────────────────────────────────────────
|
||||
distance_au = _derive_distance_au(period, star_type)
|
||||
|
||||
# ── Axial tilt ────────────────────────────────────────────────────────
|
||||
tilt_ov = (ov.get("orbit", {}) or {}).get("axial_tilt_deg", "rand")
|
||||
if tilt_ov != "rand":
|
||||
axial_tilt = float(tilt_ov)
|
||||
elif _is_tidally_locked(period, star_type) and not parent_is_giant:
|
||||
axial_tilt = _rand_range(bid, 0, 5, "tilt")
|
||||
elif planet_class in CLASS_TILT:
|
||||
lo, hi = CLASS_TILT[planet_class]
|
||||
axial_tilt = _rand_range(bid, lo, hi, "tilt")
|
||||
else:
|
||||
axial_tilt = _rand_range(bid, 5, 35, "tilt")
|
||||
|
||||
# ── Atmosphere density ────────────────────────────────────────────────
|
||||
atmo_density = ATMO_MAP.get(atmo, "none")
|
||||
atmo_density = ov.get("atmosphere_density", atmo_density)
|
||||
|
||||
# ── Atmosphere colour — from star type + planet class ─────────────────
|
||||
atmo_colors = {
|
||||
"temperate": [0.45, 0.65, 1.00],
|
||||
"oceanic": [0.40, 0.60, 1.00],
|
||||
"forest": [0.42, 0.68, 0.80],
|
||||
"arid": [0.90, 0.72, 0.50],
|
||||
"frozen": [0.75, 0.88, 1.00],
|
||||
"volcanic": [0.55, 0.40, 0.30],
|
||||
"barren": None,
|
||||
}
|
||||
atmo_color = atmo_colors.get(planet_class)
|
||||
atmo_color = ov.get("atmosphere_color", atmo_color)
|
||||
|
||||
# ── Land fraction ─────────────────────────────────────────────────────
|
||||
land_ov = (ov.get("terrain", {}) or {}).get("land_fraction", "rand")
|
||||
if land_ov != "rand":
|
||||
land_fraction = float(land_ov)
|
||||
else:
|
||||
lo, hi = HYDRO_LAND.get(hydro, (0.90, 0.99))
|
||||
land_fraction = _rand_range(bid, lo, hi, "land")
|
||||
|
||||
# ── Polar ice latitude ────────────────────────────────────────────────
|
||||
ice_ov = (ov.get("terrain", {}) or {}).get("polar_ice_lat", "rand")
|
||||
if ice_ov != "rand":
|
||||
polar_ice_lat = float(ice_ov)
|
||||
else:
|
||||
lo, hi = CLASS_POLAR_ICE.get(planet_class, (0.80, 0.95))
|
||||
# High axial tilt → ice caps extend further toward equator
|
||||
tilt_factor = (axial_tilt / 90.0) * 0.3
|
||||
lo = max(0.05, lo - tilt_factor)
|
||||
hi = max(0.10, hi - tilt_factor)
|
||||
polar_ice_lat = _rand_range(bid, lo, hi, "ice")
|
||||
|
||||
# ── Tectonics ─────────────────────────────────────────────────────────
|
||||
tectonic_map = {
|
||||
"volcanic": "extreme", "temperate": "active",
|
||||
"oceanic": "active", "forest": "active",
|
||||
"arid": "low", "frozen": "low", "barren": "none",
|
||||
}
|
||||
tectonics = tectonic_map.get(planet_class, "low")
|
||||
tectonics = ov.get("tectonics", tectonics)
|
||||
|
||||
# ── Geothermal flux ───────────────────────────────────────────────────
|
||||
geothermal = CLASS_GEOTHERMAL.get(planet_class, "low")
|
||||
# Tidal heating for moons of gas giants
|
||||
if parent_is_giant:
|
||||
tidal = _tidal_heating(period, mass, parent_is_giant)
|
||||
if tidal != "low":
|
||||
geothermal = tidal
|
||||
geothermal = ov.get("geothermal_flux", geothermal)
|
||||
|
||||
# ── UV index ──────────────────────────────────────────────────────────
|
||||
uv_index = STAR_UV.get(star_type, "moderate")
|
||||
# Thin/no atmosphere → UV reaches surface directly
|
||||
if atmo_density in ("none", "thin"):
|
||||
uv_map = {"low": "moderate", "moderate": "high", "high": "extreme"}
|
||||
uv_index = uv_map.get(uv_index, uv_index)
|
||||
uv_index = ov.get("uv_index", uv_index)
|
||||
|
||||
# ── Substrate ─────────────────────────────────────────────────────────
|
||||
substrate_map = {
|
||||
"volcanic": "sulfuric",
|
||||
"arid": "silicate",
|
||||
"frozen": "ice",
|
||||
"barren": "silicate",
|
||||
"temperate":"silicate",
|
||||
"oceanic": "silicate",
|
||||
"forest": "silicate",
|
||||
}
|
||||
substrate = substrate_map.get(planet_class, "silicate")
|
||||
if hydro == "subsurface" and planet_class == "frozen":
|
||||
substrate = "ice"
|
||||
substrate = ov.get("substrate", substrate)
|
||||
|
||||
# ── Chemosynthetic modifier ───────────────────────────────────────────
|
||||
# Europa case: frozen + subsurface + tidal heating → chemosynthetic
|
||||
chemosynthetic = False
|
||||
if hydro == "subsurface" and geothermal in ("moderate", "high", "extreme"):
|
||||
chemosynthetic = True
|
||||
chemosynthetic = ov.get("chemosynthetic", chemosynthetic)
|
||||
|
||||
# ── Oblateness ────────────────────────────────────────────────────────
|
||||
oblat_lo, oblat_hi = CLASS_OBLATENESS.get(planet_class, (0.001, 0.005))
|
||||
oblateness = _rand_range(bid, oblat_lo, oblat_hi, "oblat")
|
||||
if btype == "gas_giant" or mass in ("gas_giant", "ice_giant"):
|
||||
oblateness = _rand_range(bid, 0.050, 0.090, "oblat")
|
||||
oblateness = ov.get("oblateness", oblateness)
|
||||
|
||||
# ── Clouds ────────────────────────────────────────────────────────────
|
||||
clouds_enabled = atmo_density in CLOUD_CAPABLE and planet_class != "barren"
|
||||
if planet_class == "barren":
|
||||
clouds_enabled = False
|
||||
cld_ov = ov.get("clouds", {}) or {}
|
||||
clouds_enabled = cld_ov.get("enabled", clouds_enabled)
|
||||
|
||||
coverage_ov = cld_ov.get("coverage_base", "rand")
|
||||
if coverage_ov != "rand":
|
||||
coverage = float(coverage_ov)
|
||||
else:
|
||||
lo, hi = CLASS_CLOUD.get(planet_class, (0.10, 0.40))
|
||||
coverage = _rand_range(bid, lo, hi, "cloud")
|
||||
|
||||
# ── Gas giant specific ────────────────────────────────────────────────
|
||||
gas_giant_cfg = None
|
||||
rings_cfg = None
|
||||
|
||||
if planet_class == "gas_giant":
|
||||
palette_ov = (ov.get("gas_giant", {}) or {}).get("band_palette", "rand")
|
||||
if palette_ov == "rand":
|
||||
palette = _rand_choice(bid, GAS_PALETTES, "palette")
|
||||
else:
|
||||
palette = palette_ov
|
||||
|
||||
storm_count = int(_rand_range(bid, 1, 5, "storms"))
|
||||
storm_count = (ov.get("gas_giant", {}) or {}).get("storm_count", storm_count)
|
||||
storm_size = _rand_range(bid, 0.06, 0.14, "storm_sz")
|
||||
storm_size = (ov.get("gas_giant", {}) or {}).get("storm_max_size", storm_size)
|
||||
|
||||
gas_giant_cfg = {
|
||||
"band_palette": palette,
|
||||
"storm_count": storm_count,
|
||||
"storm_max_size": round(float(storm_size), 3),
|
||||
}
|
||||
|
||||
# Rings
|
||||
rings_ov = ov.get("rings", "rand")
|
||||
if rings_ov == "rand":
|
||||
has_rings = _rand_bool(bid, RING_PROBABILITY, "rings")
|
||||
elif isinstance(rings_ov, dict):
|
||||
has_rings = rings_ov.get("enabled", True)
|
||||
else:
|
||||
has_rings = bool(rings_ov)
|
||||
|
||||
if has_rings:
|
||||
planet_class = "gas_giant_ringed"
|
||||
r_inner = round(_rand_range(bid, 1.08, 1.25, "r_inner"), 2)
|
||||
r_outer = round(_rand_range(bid, 2.20, 2.80, "r_outer"), 2)
|
||||
opacity = round(_rand_range(bid, 0.45, 0.72, "r_opa"), 2)
|
||||
rcolor = RING_COLOURS.get(palette, [0.75, 0.70, 0.60])
|
||||
|
||||
# Merge any explicit ring overrides
|
||||
if isinstance(rings_ov, dict):
|
||||
r_inner = rings_ov.get("inner_radius_factor", r_inner)
|
||||
r_outer = rings_ov.get("outer_radius_factor", r_outer)
|
||||
opacity = rings_ov.get("opacity_base", opacity)
|
||||
rcolor = rings_ov.get("ring_color", rcolor)
|
||||
|
||||
rings_cfg = {
|
||||
"enabled": True,
|
||||
"inner_radius_factor": r_inner,
|
||||
"outer_radius_factor": r_outer,
|
||||
"opacity_base": opacity,
|
||||
"ring_color": rcolor,
|
||||
}
|
||||
|
||||
# ── Render config ─────────────────────────────────────────────────────
|
||||
render_cfg = dict(RENDER_DEFAULTS)
|
||||
render_cfg["specular_ocean"] = hydro in ("ocean", "liquid_water", "rivers")
|
||||
if planet_class in ("barren", "arid", "volcanic"):
|
||||
render_cfg["specular_ocean"] = False
|
||||
render_cfg.update(ov.get("render", {}))
|
||||
|
||||
# ── Assemble ──────────────────────────────────────────────────────────
|
||||
body_def = {
|
||||
"id": bid,
|
||||
"name": row["name"],
|
||||
"body_type": btype,
|
||||
"planet_class": planet_class,
|
||||
"body_scale": body_scale,
|
||||
"seed": seed,
|
||||
|
||||
"star": star,
|
||||
|
||||
"orbit": {
|
||||
"distance_au": round(distance_au, 3),
|
||||
"period_days": period,
|
||||
"axial_tilt_deg": round(axial_tilt, 1),
|
||||
},
|
||||
|
||||
"physical": {
|
||||
"gravity_g": gravity,
|
||||
"oblateness": round(oblateness, 4),
|
||||
"atmosphere": atmo_density,
|
||||
"atmosphere_color": atmo_color,
|
||||
},
|
||||
|
||||
"terrain": {
|
||||
"land_fraction": round(land_fraction, 3),
|
||||
"polar_ice_lat": round(polar_ice_lat, 3),
|
||||
"tectonics": tectonics,
|
||||
},
|
||||
|
||||
"environment": {
|
||||
"geothermal_flux": geothermal,
|
||||
"uv_index": uv_index,
|
||||
"substrate": substrate,
|
||||
"chemosynthetic": chemosynthetic,
|
||||
"hydrosphere": hydro,
|
||||
},
|
||||
|
||||
"clouds": {
|
||||
"enabled": bool(clouds_enabled),
|
||||
"coverage_base": round(coverage, 3),
|
||||
},
|
||||
|
||||
"render": render_cfg,
|
||||
}
|
||||
|
||||
# Gas giant extras
|
||||
if gas_giant_cfg:
|
||||
body_def["gas_giant"] = gas_giant_cfg
|
||||
if rings_cfg:
|
||||
body_def["rings"] = rings_cfg
|
||||
|
||||
# Wiki cultural data — not used by the generator, carried for the
|
||||
# body index.md template and downstream pipelines.
|
||||
pop_raw = row.get("population", "—")
|
||||
body_def["wiki"] = {
|
||||
"inhabited": row.get("inhabited", False),
|
||||
"population": pop_raw if pop_raw not in ("—", "", None) else None,
|
||||
"economy": row.get("economy") if row.get("economy") not in ("—", "", None) else None,
|
||||
"settlement": row.get("settlement") if row.get("settlement") not in ("—", "", None) else None,
|
||||
"industrial": row.get("industrial") if row.get("industrial") not in ("—", "", None) else None,
|
||||
}
|
||||
|
||||
return body_def
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System parser — top-level entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_system(
|
||||
md_path: str,
|
||||
overrides: dict = None,
|
||||
out_dir: str = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Parse a system index.md and return list of body_definition dicts.
|
||||
Optionally write one JSON file per body into out_dir.
|
||||
|
||||
overrides: { body_id: { field: value, ... } }
|
||||
"""
|
||||
overrides = overrides or {}
|
||||
md_text = Path(md_path).read_text(encoding="utf-8")
|
||||
|
||||
# Extract system ID from first header
|
||||
sys_match = re.search(r'\*\*([A-Z0-9 ]+)\*\*', md_text)
|
||||
system_id = sys_match.group(1).replace(" ", "_") if sys_match else "UNKNOWN"
|
||||
|
||||
# Parse star
|
||||
star = _parse_star(md_text)
|
||||
log.info(f"System: {system_id} Star: {star['type']}-type "
|
||||
f"L={star['luminosity_solar']:.3g} Lsun")
|
||||
|
||||
# Parse bodies table
|
||||
rows = _parse_bodies_table(md_text)
|
||||
log.info(f"Found {len(rows)} renderable bodies")
|
||||
|
||||
# Track which bodies are moons of gas giants (for tidal heating)
|
||||
# Simple heuristic: if the previous non-moon row was a gas_giant, this is its moon
|
||||
last_giant = False
|
||||
body_defs = []
|
||||
|
||||
for row in rows:
|
||||
bid = row["body_id"]
|
||||
btype = row["body_type"]
|
||||
mass = row["mass_class"]
|
||||
|
||||
is_giant = btype == "gas_giant" or mass in ("gas_giant", "ice_giant")
|
||||
|
||||
# Determine if this moon orbits a gas giant
|
||||
parent_is_giant = row["is_moon_row"] and last_giant
|
||||
|
||||
if not row["is_moon_row"]:
|
||||
last_giant = is_giant
|
||||
|
||||
# Build definition
|
||||
body_def = _build_body_def(
|
||||
row, star, system_id, overrides,
|
||||
parent_is_giant=parent_is_giant,
|
||||
)
|
||||
if body_def is None:
|
||||
continue
|
||||
|
||||
body_defs.append(body_def)
|
||||
log.info(f" {bid:20s} {body_def['planet_class']:20s} "
|
||||
f"scale={body_def['body_scale']:6s} "
|
||||
f"seed={body_def['seed']}")
|
||||
|
||||
# Write output files
|
||||
if out_dir:
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
for bd in body_defs:
|
||||
out_path = os.path.join(out_dir, f"{bd['id']}_def.json")
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(bd, f, indent=2)
|
||||
log.info(f"Wrote {len(body_defs)} body definitions → {out_dir}/")
|
||||
|
||||
_check_habitability(body_def)
|
||||
return body_defs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Parse system index.md → body_definition.json files"
|
||||
)
|
||||
parser.add_argument("md_file", help="Path to system index.md")
|
||||
parser.add_argument("--out-dir", default="./body_defs",
|
||||
help="Output directory for JSON files (default: ./body_defs)")
|
||||
parser.add_argument("--overrides", default=None,
|
||||
help="Path to JSON overrides file (optional)")
|
||||
parser.add_argument("--print", action="store_true",
|
||||
help="Print all body definitions to stdout")
|
||||
args = parser.parse_args()
|
||||
|
||||
overrides = {}
|
||||
if args.overrides:
|
||||
with open(args.overrides) as f:
|
||||
overrides = json.load(f)
|
||||
|
||||
defs = parse_system(args.md_file, overrides=overrides, out_dir=args.out_dir)
|
||||
|
||||
if args.print:
|
||||
print(json.dumps(defs, indent=2))
|
||||
Executable
+5
@@ -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" "$@"
|
||||
@@ -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()
|
||||
@@ -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.")
|
||||
@@ -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}/")
|
||||
@@ -0,0 +1,486 @@
|
||||
"""
|
||||
render_heightmap.py
|
||||
-------------------
|
||||
Renders a 4096×2048 annotated equirectangular heightmap PNG from a terrain dict.
|
||||
|
||||
This is the PRIMARY output of the planet generator pipeline.
|
||||
The globe render is a separate downstream step that reads the same terrain dict.
|
||||
|
||||
Equirectangular projection:
|
||||
X axis: longitude 0°→360° (left to right)
|
||||
Y axis: latitude +90°→-90° (top to bottom, north pole at row 0)
|
||||
|
||||
Each terrain grid cell maps to a block of output pixels via bicubic upscale.
|
||||
All rendering is in float32; final conversion to uint8 at save time.
|
||||
|
||||
Output layers (composited in order):
|
||||
1. Biome colour — smooth-blended from Whittaker grid, not hard-snapped
|
||||
2. Elevation shading — subtle darkening in valleys, lightening on peaks
|
||||
3. Hillshade — surface normal lighting pass (makes terrain 3D-readable)
|
||||
4. Coastline — 1px dark border at sea level threshold
|
||||
5. Rivers — anti-aliased polylines from river list
|
||||
6. Lat/lon grid — every 30°, semi-transparent
|
||||
7. Title panel — body metadata strip at top
|
||||
8. Legend — biome colour swatches at bottom
|
||||
|
||||
Geographic only. No settlements, roads, or cultural data.
|
||||
Those live in a separate JSON sidecar and are overlaid by the atlas app.
|
||||
|
||||
Usage:
|
||||
from render_heightmap import render_heightmap
|
||||
from planet_simulation import simulate
|
||||
from body_definition_parser import parse_system
|
||||
|
||||
defs = parse_system("index.md")
|
||||
terrain = simulate(defs[0])
|
||||
img = render_heightmap(defs[0], terrain)
|
||||
img.save("GJ144d_heightmap.png")
|
||||
"""
|
||||
|
||||
import math
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
from scipy.ndimage import gaussian_filter, binary_dilation
|
||||
|
||||
from biome_config import (
|
||||
BIOME_PALETTE as _BIOME_PALETTE_CFG,
|
||||
RIVER_RGB as _RIVER_RGB_CFG,
|
||||
COAST_RGB as _COAST_RGB_CFG,
|
||||
MAX_BIOME_ID,
|
||||
build_biome_rgb,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
OUT_W = 4096
|
||||
OUT_H = 2048
|
||||
UI_SCALE = OUT_W / 1024 # 4.0 — all pixel sizes scale with this
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Biome colour palette
|
||||
# Indices match planet_simulation.WHITTAKER_TABLE class IDs.
|
||||
# Extended exotic classes appended at end.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Biome palette loaded from biomes.toml via biome_config.
|
||||
# Per-planet overrides can patch _BIOME_PALETTE_CFG before rendering.
|
||||
BIOME_PALETTE = _BIOME_PALETTE_CFG
|
||||
|
||||
def _build_biome_rgb(mode: str = "cartographic") -> dict:
|
||||
return build_biome_rgb(mode)
|
||||
|
||||
RENDER_MODE = "cartographic"
|
||||
BIOME_RGB = _build_biome_rgb(RENDER_MODE)
|
||||
|
||||
def _ocean_arrays(mode: str = "cartographic"):
|
||||
return (
|
||||
np.array(BIOME_PALETTE[0][mode], dtype=np.float32),
|
||||
np.array(BIOME_PALETTE[1][mode], dtype=np.float32),
|
||||
np.array(BIOME_PALETTE[2][mode], dtype=np.float32),
|
||||
)
|
||||
|
||||
OCEAN_DEEP, OCEAN_MID, OCEAN_SHALLOW = _ocean_arrays(RENDER_MODE)
|
||||
|
||||
RIVER_RGB = _RIVER_RGB_CFG
|
||||
COAST_RGB = _COAST_RGB_CFG
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _upscale(grid: np.ndarray, order: int = 1) -> np.ndarray:
|
||||
"""
|
||||
Upscale a (GRID_H, GRID_W) float32 grid to (OUT_H, OUT_W).
|
||||
order=1 → bilinear (smooth, good for continuous fields)
|
||||
order=0 → nearest (sharp, good for integer class grids)
|
||||
"""
|
||||
from scipy.ndimage import zoom
|
||||
zy = OUT_H / grid.shape[0]
|
||||
zx = OUT_W / grid.shape[1]
|
||||
return zoom(grid.astype(np.float32), (zy, zx), order=order).astype(np.float32)
|
||||
|
||||
|
||||
def _upscale_int(grid: np.ndarray) -> np.ndarray:
|
||||
"""Nearest-neighbour upscale for integer class grids (biome, etc)."""
|
||||
from scipy.ndimage import zoom
|
||||
zy = OUT_H / grid.shape[0]
|
||||
zx = OUT_W / grid.shape[1]
|
||||
return zoom(grid.astype(np.int32), (zy, zx), order=0).astype(np.int8)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 1 + 2 + 3: Biome colour + elevation shading + hillshade
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _render_surface(terrain: dict) -> np.ndarray:
|
||||
"""
|
||||
Returns (OUT_H, OUT_W, 3) float32 RGB in [0, 1].
|
||||
|
||||
Compositing order:
|
||||
biome_colour × elevation_shade × hillshade_factor
|
||||
"""
|
||||
elevation = _upscale(terrain["elevation"], order=1)
|
||||
hillshade = _upscale(terrain["hillshade"], order=1)
|
||||
biome_up = _upscale_int(terrain["biome"])
|
||||
surf_water = _upscale(terrain["surface_water"].astype(np.float32),
|
||||
order=0) > 0.5
|
||||
sea_level = terrain["sea_level"]
|
||||
|
||||
H, W = elevation.shape
|
||||
|
||||
# ── Biome base colour ─────────────────────────────────────────────────
|
||||
# Clamp biome index, look up palette
|
||||
# Build lookup array from active BIOME_RGB dict for vectorised indexing
|
||||
max_id = max(BIOME_RGB.keys())
|
||||
pal_arr = np.zeros((max_id + 1, 3), dtype=np.float32)
|
||||
for k, v in BIOME_RGB.items():
|
||||
pal_arr[k] = v
|
||||
biome_clamped = np.clip(biome_up, 0, max_id)
|
||||
rgb = pal_arr[biome_clamped].astype(np.float32) / 255.0
|
||||
|
||||
# ── Ocean depth blending ───────────────────────────────────────────────
|
||||
# Override flat ocean biome with smooth depth gradient
|
||||
if surf_water.any():
|
||||
depth = np.clip((sea_level - elevation) / (sea_level + 1e-9), 0, 1)
|
||||
deep_col = OCEAN_DEEP / 255.0
|
||||
mid_col = OCEAN_MID / 255.0
|
||||
shallow_col = OCEAN_SHALLOW / 255.0
|
||||
|
||||
# Three-stop blend: 0=shallow, 0.5=mid, 1=deep
|
||||
t1 = np.clip(depth * 2.0, 0, 1) # 0→0.5 depth: shallow→mid
|
||||
t2 = np.clip((depth - 0.5) * 2.0, 0, 1) # 0.5→1 depth: mid→deep
|
||||
ocean_rgb = (shallow_col * (1 - t1)[..., None]
|
||||
+ mid_col * (t1 * (1 - t2))[..., None]
|
||||
+ deep_col * t2[..., None])
|
||||
rgb = np.where(surf_water[..., None], ocean_rgb, rgb)
|
||||
|
||||
# ── Elevation shading on land ──────────────────────────────────────────
|
||||
# Slight darkening in lowlands, brightening on ridges
|
||||
elev_norm = np.where(
|
||||
~surf_water,
|
||||
np.clip((elevation - sea_level) / (1.0 - sea_level + 1e-9), 0, 1),
|
||||
0.0)
|
||||
elev_shade = 0.88 + 0.18 * elev_norm # [0.88, 1.06] — clamp below
|
||||
rgb = np.where(~surf_water[..., None],
|
||||
np.clip(rgb * elev_shade[..., None], 0, 1),
|
||||
rgb)
|
||||
|
||||
# ── Hillshade ──────────────────────────────────────────────────────────
|
||||
# Apply only on land — ocean gets its own depth shading
|
||||
# Blend factor: 0.55 hillshade + 0.45 flat (keeps colours readable)
|
||||
hs_blend = 0.55 * hillshade + 0.45
|
||||
rgb = np.where(~surf_water[..., None],
|
||||
np.clip(rgb * hs_blend[..., None], 0, 1),
|
||||
rgb)
|
||||
|
||||
return rgb.astype(np.float32)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 4: Coastline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _render_coastline(terrain: dict,
|
||||
rgb: np.ndarray) -> np.ndarray:
|
||||
"""Draw a 1–2px dark border at the sea level threshold."""
|
||||
surf_water = _upscale(terrain["surface_water"].astype(np.float32),
|
||||
order=0) > 0.5
|
||||
|
||||
# Dilate water mask by 1px, XOR with original → coastline ring
|
||||
dilated = binary_dilation(surf_water, iterations=2)
|
||||
coastline = dilated & ~surf_water
|
||||
|
||||
coast_col = np.array(COAST_RGB, dtype=np.float32) / 255.0
|
||||
out = rgb.copy()
|
||||
out[coastline] = coast_col
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 5: Rivers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _render_rivers(terrain: dict,
|
||||
rgb: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Draw rivers as anti-aliased polylines.
|
||||
River list is in simulation grid coords (row, col) at GRID_H×GRID_W.
|
||||
Scale to output pixels, draw with PIL.
|
||||
"""
|
||||
rivers = terrain.get("rivers", [])
|
||||
if not rivers:
|
||||
return rgb
|
||||
|
||||
GRID_H, GRID_W = terrain["_grid_h"], terrain["_grid_w"]
|
||||
scale_y = OUT_H / GRID_H
|
||||
scale_x = OUT_W / GRID_W
|
||||
|
||||
# Work on a PIL image for anti-aliased line drawing
|
||||
img = Image.fromarray((rgb * 255).clip(0, 255).astype(np.uint8), mode="RGB")
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
river_col = RIVER_RGB
|
||||
|
||||
for path in rivers:
|
||||
if len(path) < 2:
|
||||
continue
|
||||
# Scale grid coords to output pixels
|
||||
pts = [(int(c * scale_x), int(r * scale_y)) for r, c in path]
|
||||
# Line width scales with path length — longer rivers are wider.
|
||||
# Base width doubled for readability at high output resolutions.
|
||||
width = max(2, min(6, len(path) // 40))
|
||||
draw.line(pts, fill=river_col, width=width, joint="curve")
|
||||
|
||||
return np.array(img).astype(np.float32) / 255.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 6: Lat/lon grid
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _render_grid(rgb: np.ndarray) -> np.ndarray:
|
||||
"""Draw lat/lon lines every 30° as semi-transparent overlays."""
|
||||
out = rgb.copy()
|
||||
col = np.array([255, 255, 255], dtype=np.float32) / 255.0
|
||||
alpha = 0.12 # very subtle
|
||||
|
||||
# Latitude lines (horizontal) every 30°: at 1/6, 2/6, 3/6, 4/6, 5/6 of height
|
||||
for frac in [1/6, 2/6, 3/6, 4/6, 5/6]:
|
||||
y = int(frac * OUT_H)
|
||||
y0 = max(0, y - 1); y1 = min(OUT_H - 1, y + 1)
|
||||
out[y0:y1, :] = out[y0:y1, :] * (1 - alpha) + col * alpha
|
||||
|
||||
# Longitude lines (vertical) every 30°
|
||||
for frac in [1/6, 2/6, 3/6, 4/6, 5/6]:
|
||||
x = int(frac * OUT_W)
|
||||
x0 = max(0, x - 1); x1 = min(OUT_W - 1, x + 1)
|
||||
out[:, x0:x1] = out[:, x0:x1] * (1 - alpha) + col * alpha
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 7: Title panel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_font(size: int):
|
||||
try:
|
||||
return ImageFont.load_default(size=size)
|
||||
except TypeError:
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def _render_title(img: Image.Image, body_def: dict) -> Image.Image:
|
||||
"""Draw metadata strip at top of image."""
|
||||
panel_h = int(52 * UI_SCALE)
|
||||
panel = Image.new("RGBA", (OUT_W, panel_h), (12, 15, 22, 210))
|
||||
|
||||
img_rgba = img.convert("RGBA")
|
||||
img_rgba.paste(panel, (0, 0), panel)
|
||||
img_out = img_rgba.convert("RGB")
|
||||
draw = ImageDraw.Draw(img_out)
|
||||
|
||||
name = body_def.get("name") or body_def.get("id", "Unknown")
|
||||
bid = body_def.get("id", "")
|
||||
pclass = body_def.get("planet_class", "").replace("_ringed", "")
|
||||
star = body_def.get("star", {})
|
||||
orbit = body_def.get("orbit", {})
|
||||
phys = body_def.get("physical", {})
|
||||
env = body_def.get("environment", {})
|
||||
|
||||
star_str = f"{star.get('type','?')}-type"
|
||||
dist_str = f"{orbit.get('distance_au', 0):.2f} AU"
|
||||
grav_str = f"{phys.get('gravity_g', '?')}g"
|
||||
atmo_str = phys.get("atmosphere", "?")
|
||||
hydro_str = env.get("hydrosphere", "?")
|
||||
|
||||
px = int(14 * UI_SCALE)
|
||||
py = int(7 * UI_SCALE)
|
||||
lh = int(17 * UI_SCALE)
|
||||
|
||||
title_col = (200, 210, 228)
|
||||
sub_col = (130, 145, 168)
|
||||
dim_col = (75, 88, 110)
|
||||
|
||||
line1 = f"{name.upper()} · {bid} · {pclass}"
|
||||
line2 = f"{star_str} · {dist_str} · {grav_str} · atmo: {atmo_str} · hydro: {hydro_str}"
|
||||
line3 = "HEIGHTMAP · Settled Reach"
|
||||
|
||||
draw.text((px, py), line1, fill=title_col, font=_load_font(int(14 * UI_SCALE)))
|
||||
draw.text((px, py + lh), line2, fill=sub_col, font=_load_font(int(12 * UI_SCALE)))
|
||||
draw.text((px, py + lh*2), line3, fill=dim_col, font=_load_font(int(11 * UI_SCALE)))
|
||||
|
||||
return img_out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 8: Legend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _biome_legend_items(terrain: dict) -> list:
|
||||
"""
|
||||
Return list of (label, RGB) for biome classes actually present
|
||||
in this terrain — no phantom legend entries.
|
||||
"""
|
||||
biome = terrain["biome"]
|
||||
present = set(np.unique(biome).tolist())
|
||||
|
||||
LABELS = {
|
||||
0: "ocean deep", 1: "ocean", 2: "coastal water",
|
||||
3: "coast", 5: "rainforest", 6: "trop. forest",
|
||||
7: "savanna", 8: "grassland", 9: "forest",
|
||||
10: "rainforest", 11: "boreal", 12: "shrubland",
|
||||
13: "temperate desert", 14: "desert", 15: "hot desert",
|
||||
16: "tundra", 17: "ice / snow", 18: "mountain rock",
|
||||
19: "lava field", 20: "chemosyn. mat", 21: "thermophilic",
|
||||
22: "sulfuric scrub", 23: "crypto. crust", 25: "ash field",
|
||||
}
|
||||
|
||||
items = []
|
||||
# Fixed display order — most common first, exotic last
|
||||
order = [0, 1, 2, 3, 7, 8, 5, 6, 9, 10, 11, 12, 13, 14, 15, 16, 17,
|
||||
18, 19, 20, 21, 22, 23, 25]
|
||||
for cls_id in order:
|
||||
if cls_id in present and cls_id in LABELS:
|
||||
rgb = BIOME_RGB.get(cls_id, (128, 128, 128))
|
||||
items.append((LABELS[cls_id], rgb))
|
||||
|
||||
# Always include river swatch if rivers exist
|
||||
if terrain.get("rivers"):
|
||||
items.append(("river", RIVER_RGB))
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _render_legend(img: Image.Image, terrain: dict) -> Image.Image:
|
||||
"""Draw biome legend strip at bottom of image."""
|
||||
items = _biome_legend_items(terrain)
|
||||
if not items:
|
||||
return img
|
||||
|
||||
draw = ImageDraw.Draw(img)
|
||||
sw = int(14 * UI_SCALE) # swatch width
|
||||
sh = int(12 * UI_SCALE) # swatch height
|
||||
pad_x = int(14 * UI_SCALE)
|
||||
leg_y = OUT_H - int(34 * UI_SCALE)
|
||||
font = _load_font(int(10 * UI_SCALE))
|
||||
gap = int(6 * UI_SCALE)
|
||||
step = int(108 * UI_SCALE)
|
||||
|
||||
lx = pad_x
|
||||
for label, rgb in items:
|
||||
if lx + step > OUT_W - pad_x:
|
||||
break
|
||||
draw.rectangle([(lx, leg_y), (lx + sw, leg_y + sh)], fill=rgb)
|
||||
draw.text((lx + sw + gap, leg_y), label,
|
||||
fill=(185, 192, 205), font=font)
|
||||
lx += step
|
||||
|
||||
return img
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def render_heightmap(body_def: dict,
|
||||
terrain: dict,
|
||||
out_w: int = OUT_W,
|
||||
out_h: int = OUT_H,
|
||||
render_mode: str = "cartographic",
|
||||
chrome: bool = True) -> Image.Image:
|
||||
"""
|
||||
Render a 4096×2048 annotated equirectangular heightmap PNG.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
body_def : dict — body definition from body_definition_parser
|
||||
terrain : dict — terrain dict from planet_simulation.simulate()
|
||||
out_w, out_h — output resolution (default 4096×2048)
|
||||
|
||||
Returns
|
||||
-------
|
||||
PIL.Image.Image RGB
|
||||
"""
|
||||
global OUT_W, OUT_H, UI_SCALE
|
||||
OUT_W = out_w
|
||||
OUT_H = out_h
|
||||
UI_SCALE = out_w / 1024
|
||||
|
||||
# Set active colour mode for this render
|
||||
global BIOME_RGB, OCEAN_DEEP, OCEAN_MID, OCEAN_SHALLOW, RENDER_MODE
|
||||
RENDER_MODE = render_mode
|
||||
BIOME_RGB = _build_biome_rgb(render_mode)
|
||||
OCEAN_DEEP, OCEAN_MID, OCEAN_SHALLOW = _ocean_arrays(render_mode)
|
||||
|
||||
# Guard: require simulation data
|
||||
required = ("elevation", "biome", "surface_water", "hillshade", "sea_level")
|
||||
missing = [k for k in required if k not in terrain]
|
||||
if missing:
|
||||
raise ValueError(f"terrain dict missing keys: {missing}")
|
||||
|
||||
# 1+2+3: surface colour with elevation shading and hillshade
|
||||
rgb = _render_surface(terrain)
|
||||
|
||||
# 4: coastline
|
||||
rgb = _render_coastline(terrain, rgb)
|
||||
|
||||
# 5: rivers
|
||||
rgb = _render_rivers(terrain, rgb)
|
||||
|
||||
# 6: lat/lon grid
|
||||
rgb = _render_grid(rgb)
|
||||
|
||||
# Convert to PIL for text rendering
|
||||
img = Image.fromarray(
|
||||
(rgb * 255).clip(0, 255).astype(np.uint8), mode="RGB")
|
||||
|
||||
if chrome:
|
||||
# 7: title panel
|
||||
img = _render_title(img, body_def)
|
||||
# 8: legend
|
||||
img = _render_legend(img, terrain)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys, json, time, os
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 render_heightmap.py body_def.json [--small]")
|
||||
sys.exit(1)
|
||||
|
||||
with open(sys.argv[1]) as f:
|
||||
bd = json.load(f)
|
||||
|
||||
# --small flag renders at 1024×512 for fast iteration
|
||||
small = "--small" in sys.argv
|
||||
w, h = (1024, 512) if small else (OUT_W, OUT_H)
|
||||
|
||||
from planet_simulation import simulate
|
||||
|
||||
print(f"Simulating: {bd['id']} ({bd['planet_class']})")
|
||||
t0 = time.time()
|
||||
terrain = simulate(bd)
|
||||
sim_t = time.time() - t0
|
||||
|
||||
if not terrain:
|
||||
print("Gas giant — no heightmap.")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"Rendering heightmap {w}×{h}…")
|
||||
t1 = time.time()
|
||||
img = render_heightmap(bd, terrain, out_w=w, out_h=h)
|
||||
ren_t = time.time() - t1
|
||||
|
||||
out = f"/mnt/user-data/outputs/{bd['id']}_heightmap.png"
|
||||
img.save(out, format="PNG")
|
||||
print(f"Saved: {out}")
|
||||
print(f" simulate={sim_t:.1f}s render={ren_t:.1f}s total={sim_t+ren_t:.1f}s")
|
||||
@@ -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"")
|
||||
lines.append("")
|
||||
if pclass not in ("gas_giant",):
|
||||
lines.append(f"")
|
||||
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()
|
||||
Reference in New Issue
Block a user