Files
jpmschweitzerandClaude Opus 4.7 cabdd7c097 refactor(tooling): bump planet sim to native 1024×512, drop compute_rivers (#963)
- planet_simulation: GRID 512×256 → 1024×512. The elevation noise is
  resolution-independent (normalized coords + absolute freqs), so the
  finer grid samples the SAME terrain — features keep physical size,
  generation stays deterministic. Pixel-unit constants (gaussian sigma,
  crater radii, peak-filter window, erosion slope) scale by GRID_W/512.
  Validated: non-Sol bodies render same-world-crisper at 1024.
- Remove compute_rivers + _rivers_to_grid + the rivers/river_grid terrain
  keys: rivers are the Rust cascade's job (D8 drainage, D-208), the single
  source of river truth. The old heuristic didn't even reach the sea.
- render_heightmap: stop painting rivers onto the relief (cascade/Atlas
  overlay computed rivers instead).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 01:31:52 +02:00

296 lines
12 KiB
Python
Raw Permalink 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
"""
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())}")
# ── 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()