Files
jpmschweitzerandClaude Opus 4.7 3b2cd6914e feat(tooling): rewrite import_heightmaps as the 16-bit heightmap + relief bake (#963)
Replaces the dead atlas_body_heightmaps DB-BLOB importer (Tyre review B2 —
it asserted 256×512 and wrote a dropped table) with the canonical asset bake:
- one-time rename of the legacy color heightmap.png → reliefmap.png for ALL
  bodies (incl. Sol — display-file rename only, no re-sim);
- for each non-Sol inhabited body: simulate() at 1024×512 → clean reliefmap.png
  (render_heightmap, no painted features) + 16-bit grayscale heightmap.png with
  sea_level in a tEXt chunk;
- no systems.db writes; uses parse_system for proper body defs; run via uv.

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

170 lines
6.7 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
"""
import_heightmaps.py — bake per-body canonical elevation assets (D-202 amended, #963).
This is the build-time elevation producer. For each NON-Sol inhabited body it
runs planet_simulation.simulate() at the canonical 1024×512 grid and writes two
files into the body's wiki directory:
reliefmap.png — clean color hypsometric render (display / Atlas), 1024×512
heightmap.png — 16-bit GRAYSCALE elevation, the canonical cascade input,
with sea_level stored in a PNG tEXt chunk
It also performs the one-time rename of the legacy color `heightmap.png`
(which was a relief render) → `reliefmap.png` across ALL bodies, including Sol
(GJ-0) — Sol's terrain is real-geography (sol_import) and is NOT re-simulated,
only its display file is renamed.
No systems.db writes: the PNG is the store (the atlas_body_heightmaps BLOB
table is dropped). Run with uv (numpy/scipy/Pillow):
uv run python tooling/planet-gen/import_heightmaps.py # full bake
uv run python tooling/planet-gen/import_heightmaps.py --limit 3 # smoke test
uv run python tooling/planet-gen/import_heightmaps.py --dry-run
Exit codes: 0 = completed (possibly with per-body errors), 1 = fatal.
"""
import argparse
import glob
import sqlite3
import sys
import time
from pathlib import Path
import numpy as np
from PIL import Image
from PIL.PngImagePlugin import PngInfo
TOOLING_DIR = Path(__file__).resolve().parent
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
sys.path.insert(0, str(TOOLING_DIR))
from body_definition_parser import parse_system # noqa: E402
from planet_simulation import simulate # noqa: E402
from render_heightmap import render_heightmap # noqa: E402
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
def rename_legacy_heightmaps(dry_run: bool) -> int:
"""One-time: legacy color `heightmap.png` (a relief render) → `reliefmap.png`,
for every body. Bodies the bake re-simulates get their `reliefmap.png`
overwritten with a fresh 1024 render afterwards. Idempotent: skips bodies
that already have `reliefmap.png`."""
n = 0
for hm in sorted(glob.glob(str(WIKI_SYSTEMS / "*" / "bodies" / "*" / "heightmap.png"))):
relief = Path(hm).with_name("reliefmap.png")
if relief.exists():
continue # already migrated (or freshly baked)
if not dry_run:
Path(hm).rename(relief)
n += 1
return n
def bake_body(bd: dict, body_dir: Path, dry_run: bool) -> dict:
"""Simulate one body at 1024×512 and write reliefmap.png + 16-bit heightmap.png."""
try:
terrain = simulate(bd)
except Exception as exc: # noqa: BLE001
return {"status": "error", "message": f"simulate failed: {exc}"}
if not terrain or "elevation" not in terrain:
return {"status": "gas_giant"}
elevation = np.clip(np.asarray(terrain["elevation"], dtype=np.float32), 0.0, 1.0)
sea_level = float(terrain.get("sea_level", 0.0))
if not dry_run:
# Clean color relief (no painted features — cascade/Atlas overlay those).
relief = render_heightmap(bd, terrain, render_mode="cartographic", chrome=False)
relief.save(body_dir / "reliefmap.png")
# Canonical 16-bit grayscale elevation; sea_level rides in a tEXt chunk.
u16 = (elevation * 65535.0).astype(np.uint16)
meta = PngInfo()
meta.add_text("sea_level", f"{sea_level:.6f}")
Image.fromarray(u16).save(body_dir / "heightmap.png", pnginfo=meta)
return {"status": "baked", "shape": elevation.shape, "sea_level": sea_level}
def main() -> None:
ap = argparse.ArgumentParser(description="Bake canonical heightmap/reliefmap assets (#963)")
ap.add_argument("--db", default=str(DB_PATH))
ap.add_argument("--body", help="Bake only this body_id")
ap.add_argument("--limit", type=int, help="Bake at most N bodies (smoke test)")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--skip-rename", action="store_true", help="Skip the legacy rename pass")
args = ap.parse_args()
db_path = Path(args.db)
if not db_path.exists():
print(f"error: {db_path} not found", file=sys.stderr)
sys.exit(1)
print("\n Heightmap bake (#963, D-202 amended)")
if args.dry_run:
print(" Mode: DRY RUN (no files written)")
# 1. Universal rename of the legacy color heightmap.png → reliefmap.png.
if not args.skip_rename:
n_renamed = rename_legacy_heightmaps(args.dry_run)
print(f" Renamed legacy heightmap.png → reliefmap.png: {n_renamed} bodies")
# 2. Bake non-Sol inhabited bodies.
conn = sqlite3.connect(str(db_path))
rows = conn.execute(
"SELECT body_id, terrain_reference FROM bodies "
"WHERE inhabited=1 AND terrain_reference IS NOT NULL AND population>0 "
"AND system_id != 'GJ 0' ORDER BY body_id"
).fetchall()
conn.close()
if args.body:
rows = [r for r in rows if r[0] == args.body]
if args.limit:
rows = rows[: args.limit]
print(f" Baking {len(rows)} non-Sol inhabited bodies\n")
parsed: dict[str, list] = {}
t0 = time.time()
n_baked = n_gas = n_err = 0
for i, (body_id, tref) in enumerate(rows):
body_dir = REPO_ROOT / Path(tref).parent
sys_index = str(REPO_ROOT / Path(tref.split("/bodies/")[0]) / "index.md")
try:
defs = parsed.get(sys_index) or parse_system(sys_index)
parsed[sys_index] = defs
except Exception as exc: # noqa: BLE001
print(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR parse_system: {exc}")
n_err += 1
continue
bd = next((d for d in defs if d.get("id") == body_id), None)
if bd is None:
print(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: not in system defs")
n_err += 1
continue
ts = time.time()
res = bake_body(bd, body_dir, args.dry_run)
dt = time.time() - ts
if res["status"] == "baked":
n_baked += 1
if (i + 1) % 25 == 0 or args.limit:
print(f" [{i+1}/{len(rows)}] {body_id:18s} baked {res['shape']} "
f"sea={res['sea_level']:.3f} ({dt:.1f}s)")
elif res["status"] == "gas_giant":
n_gas += 1
else:
n_err += 1
print(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: {res.get('message','')}")
print(f"\n Done in {time.time()-t0:.0f}s — baked={n_baked} gas_giant={n_gas} errors={n_err}")
print(" Stage: git add wiki/star-systems (reliefmap.png renames + heightmap.png)")
if n_err:
sys.exit(0) # per-body errors are non-fatal; reported above
if __name__ == "__main__":
main()