Files
settled-reach/spikes/heightmap-pipeline/generate_kallast.py
T
jpmschweitzerandClaude Opus 4.6 1c6c5b9978 feat(assets): heightmap pipeline spike — prototype + test inputs
PO-built prototype (FBM terrain, Whittaker biomes, procedural globe)
with test body definitions for all planet types. Replaces pyplatec
approach. Spike validates the pipeline architecture for batch #817.

Includes handover doc, 9 test body definitions, and updated spike
pipeline documentation. Stale pyplatec outputs removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 15:54:35 +02:00

171 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Kallast (GJ144d) heightmap + globe generator — Settled Reach
Reads planet parameters from the wiki (wiki/star-systems/GJ-144/index.md),
runs the FBM+Voronoi simulation pipeline, and produces:
1. Annotated equirectangular heightmap PNG (geographic features only)
2. Globe render PNG (ray-traced sphere with terrain data wrapped onto it)
Both outputs share the same simulation run — terrain is computed once.
Pipeline:
body_definition_parser → parse wiki → body_def dict
planet_simulation → FBM+Voronoi elevation, temperature, moisture,
hillshade, rivers, biome classification
render_heightmap → annotated equirectangular PNG
planet_renderer → ray-traced globe PNG (terrain-driven surface)
Usage:
python3 generate_kallast.py [options]
--small Fast iteration: 1024×512 heightmap, 512×512 globe
--render-mode cartographic (NG map style) | photographic (orbital)
Default outputs:
/mnt/user-data/outputs/GJ144d_heightmap.png (4096×2048)
/mnt/user-data/outputs/GJ144d_globe.png (2048×2048)
"""
import argparse
import os
import sys
import time
# ── Prototype pipeline on path ────────────────────────────────────────────────
_HERE = os.path.dirname(os.path.abspath(__file__))
_PROTO = os.path.join(_HERE, "prototype")
if _PROTO not in sys.path:
sys.path.insert(0, _PROTO)
from body_definition_parser import parse_system
from planet_simulation import simulate
from render_heightmap import render_heightmap
from planet_renderer import render_globe
# ── Default paths ─────────────────────────────────────────────────────────────
# Sprint worktree root is two levels above the spike dir.
_SPRINT_ROOT = os.path.normpath(os.path.join(_HERE, "..", ".."))
DEFAULT_WIKI = os.path.join(_SPRINT_ROOT, "wiki", "star-systems", "GJ-144", "index.md")
DEFAULT_OUT_HM = "/mnt/user-data/outputs/GJ144d_heightmap.png"
DEFAULT_OUT_GL = "/mnt/user-data/outputs/GJ144d_globe.png"
DEFAULT_BODY = "GJ144d"
def main():
parser = argparse.ArgumentParser(
description="Generate Kallast (GJ144d) heightmap + globe",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--output", default=DEFAULT_OUT_HM, metavar="PATH",
help="Heightmap PNG output path")
parser.add_argument(
"--globe-output", dest="globe_output",
default=DEFAULT_OUT_GL, metavar="PATH",
help="Globe PNG output path")
parser.add_argument(
"--body-id", default=DEFAULT_BODY, metavar="ID",
help="Body ID to render (default: GJ144d)")
parser.add_argument(
"--wiki", default=DEFAULT_WIKI, metavar="PATH",
help="Path to GJ-144 wiki index.md")
parser.add_argument(
"--out-w", type=int, default=4096, metavar="N",
help="Heightmap output width (default: 4096)")
parser.add_argument(
"--out-h", type=int, default=2048, metavar="N",
help="Heightmap output height (default: 2048)")
parser.add_argument(
"--globe-size", type=int, default=2048, metavar="N",
help="Globe output size in px (default: 2048)")
parser.add_argument(
"--render-mode",
choices=["cartographic", "photographic"],
default="cartographic",
help="Heightmap colour mode (default: cartographic)")
parser.add_argument(
"--small", action="store_true",
help="Fast iteration: 1024×512 heightmap, 512×512 globe")
args = parser.parse_args()
if args.small:
args.out_w = 1024
args.out_h = 512
args.globe_size = 512
t_start = time.time()
# ── 1. Parse body definition from wiki ───────────────────────────────────
print(f"Parsing wiki: {args.wiki}")
if not os.path.exists(args.wiki):
raise SystemExit(
f"Wiki not found: {args.wiki}\n"
"Pass --wiki <path/to/index.md> if running from a non-standard location.")
bodies = parse_system(args.wiki)
body_def = next((b for b in bodies if b["id"] == args.body_id), None)
if body_def is None:
available = [b["id"] for b in bodies]
raise SystemExit(
f"Body {args.body_id!r} not found in wiki.\n"
f"Available: {available}\n"
"Use --body-id to specify a different body.")
pc = body_def.get("planet_class", "?")
seed = body_def.get("seed", 0)
print(f" {body_def['id']} class={pc} seed={seed}")
# ── 2. Simulate terrain ──────────────────────────────────────────────────
print("Simulating terrain…")
t0 = time.time()
terrain = simulate(body_def)
sim_t = time.time() - t0
if not terrain:
raise SystemExit(f"{args.body_id} is a gas giant — no terrain to render.")
sea = terrain["sea_level"]
nriv = len(terrain["rivers"])
print(f" Done in {sim_t:.1f}s "
f"sea_level={sea:.3f} rivers={nriv}")
if terrain.get("temperature_clamped"):
raw_K = terrain.get("temperature_raw_K", "?")
band = terrain.get("temperature_band_K", [])
print(f" T_raw={raw_K}K clamped to {band} for {pc}")
# ── 3. Render heightmap ──────────────────────────────────────────────────
size_str = f"{args.out_w}×{args.out_h}"
print(f"Rendering heightmap {size_str} ({args.render_mode})…")
t1 = time.time()
hm = render_heightmap(
body_def, terrain,
out_w=args.out_w, out_h=args.out_h,
render_mode=args.render_mode)
hm_t = time.time() - t1
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
hm.save(args.output, format="PNG")
print(f" Saved: {args.output} ({hm_t:.1f}s)")
# ── 4. Render globe ──────────────────────────────────────────────────────
print(f"Rendering globe {args.globe_size}×{args.globe_size}…")
t2 = time.time()
glob = render_globe(body_def, terrain=terrain, size=args.globe_size)
gl_t = time.time() - t2
os.makedirs(os.path.dirname(os.path.abspath(args.globe_output)), exist_ok=True)
glob.save(args.globe_output, format="PNG")
print(f" Saved: {args.globe_output} ({gl_t:.1f}s)")
total = time.time() - t_start
print(f"\nTotal: {total:.1f}s "
f"(sim={sim_t:.1f}s hm={hm_t:.1f}s glob={gl_t:.1f}s)")
if __name__ == "__main__":
main()