The atomic .tmp→rename pattern caused silent FileNotFoundError on some bodies. Removed in favour of direct writes — resume logic already handles interrupted runs. Fixed --heightmap-size CLI flag which was silently ignored due to Python default parameter binding. Changed default heightmap resolution from 4096x2048 to 1024x512 (native simulation grid — no information gain from upscaling). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
297 lines
12 KiB
Python
297 lines
12 KiB
Python
#!/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:
|
||
# Clean heightmap (no title/legend)
|
||
hmap_img = render_heightmap(body_def, terrain,
|
||
out_w=hmap_w, out_h=hmap_h,
|
||
render_mode=render_mode, 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,
|
||
out_w=hmap_w, out_h=hmap_h,
|
||
render_mode=render_mode, 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="1024x512",
|
||
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()
|