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>
This commit is contained in:
2026-05-23 08:22:50 +02:00
co-authored by Claude Opus 4.7
parent dd296b1ae5
commit 3b2cd6914e
+120 -170
View File
@@ -1,218 +1,168 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
import_heightmaps.py — Import terrain elevation grids into atlas_body_heightmaps. import_heightmaps.py — bake per-body canonical elevation assets (D-202 amended, #963).
For each inhabited body with a terrain_reference, simulates the terrain via This is the build-time elevation producer. For each NON-Sol inhabited body it
planet_simulation.simulate() and stores the float32 LE elevation BLOB plus runs planet_simulation.simulate() at the canonical 1024×512 grid and writes two
sea_level metadata in atlas_body_heightmaps (#906, D-202). files into the body's wiki directory:
The BLOB format matches the Rust loader spec (D-202): reliefmap.png — clean color hypsometric render (display / Atlas), 1024×512
- float32 little-endian, row-major heightmap.png — 16-bit GRAYSCALE elevation, the canonical cascade input,
- width × height values, each in [0.0, 1.0] with sea_level stored in a PNG tEXt chunk
- width = GRID_W (512), height = GRID_H (256)
Incremental: bodies that already have a row in atlas_body_heightmaps are It also performs the one-time rename of the legacy color `heightmap.png`
skipped unless --force is passed. (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.
Usage: No systems.db writes: the PNG is the store (the atlas_body_heightmaps BLOB
tooling/planet-gen/import_heightmaps.py table is dropped). Run with uv (numpy/scipy/Pillow):
tooling/planet-gen/import_heightmaps.py --body GJ380c
tooling/planet-gen/import_heightmaps.py --force
tooling/planet-gen/import_heightmaps.py --dry-run
Exit codes: uv run python tooling/planet-gen/import_heightmaps.py # full bake
0 completed (possibly with skipped or errored bodies) uv run python tooling/planet-gen/import_heightmaps.py --limit 3 # smoke test
1 fatal error (missing DB, schema error) 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 argparse
import glob
import sqlite3
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
import numpy as np
from PIL import Image
from PIL.PngImagePlugin import PngInfo
TOOLING_DIR = Path(__file__).resolve().parent TOOLING_DIR = Path(__file__).resolve().parent
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve() REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
sys.path.insert(0, str(TOOLING_DIR))
_venv_python = REPO_ROOT / ".venv" / "bin" / "python" from body_definition_parser import parse_system # noqa: E402
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve(): from planet_simulation import simulate # noqa: E402
import os from render_heightmap import render_heightmap # noqa: E402
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
import numpy as np DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
import sqlite3 WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
from atlas_common import (
GRID_W,
GRID_H,
DB_PATH,
ensure_atlas_schema,
load_body_def,
query_inhabited_bodies,
)
from planet_simulation import simulate
def _elevation_to_blob(elevation: np.ndarray) -> bytes: def rename_legacy_heightmaps(dry_run: bool) -> int:
"""Convert a float32 elevation grid to a little-endian BLOB.""" """One-time: legacy color `heightmap.png` (a relief render) → `reliefmap.png`,
arr = elevation.astype("<f4") # float32 LE, explicit for every body. Bodies the bake re-simulates get their `reliefmap.png`
assert arr.shape == (GRID_H, GRID_W), ( overwritten with a fresh 1024 render afterwards. Idempotent: skips bodies
f"elevation shape {arr.shape} does not match expected ({GRID_H}, {GRID_W})" that already have `reliefmap.png`."""
) n = 0
return arr.tobytes() 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 import_body( def bake_body(bd: dict, body_dir: Path, dry_run: bool) -> dict:
body_info: dict, """Simulate one body at 1024×512 and write reliefmap.png + 16-bit heightmap.png."""
conn: sqlite3.Connection,
force: bool,
dry_run: bool,
verbose: bool,
) -> dict:
"""Import heightmap BLOB for one body.
Returns a dict with:
status: 'imported' | 'skipped' | 'gas_giant' | 'error'
message: detail (on error or skip)
"""
body_id = body_info["body_id"]
terrain_ref = body_info["terrain_reference"]
# Incremental check — skip if already imported
if not force:
existing = conn.execute(
"SELECT 1 FROM atlas_body_heightmaps WHERE body_id = ?", (body_id,)
).fetchone()
if existing:
return {"status": "skipped", "message": "already imported"}
body_dir = REPO_ROOT / Path(terrain_ref).parent
if not body_dir.exists():
return {"status": "error", "message": f"body_dir not found: {body_dir}"}
bd = load_body_def(body_dir)
if not bd:
return {"status": "error", "message": f"no body definition found in {body_dir}"}
try: try:
terrain = simulate(bd) terrain = simulate(bd)
except Exception as exc: except Exception as exc: # noqa: BLE001
return {"status": "error", "message": f"simulate() failed: {exc}"} return {"status": "error", "message": f"simulate failed: {exc}"}
if not terrain or "elevation" not in terrain:
if not terrain:
return {"status": "gas_giant"} return {"status": "gas_giant"}
elevation = terrain.get("elevation") elevation = np.clip(np.asarray(terrain["elevation"], dtype=np.float32), 0.0, 1.0)
if elevation is None:
return {"status": "error", "message": "terrain dict missing 'elevation' key"}
sea_level = float(terrain.get("sea_level", 0.0)) sea_level = float(terrain.get("sea_level", 0.0))
blob = _elevation_to_blob(elevation)
if verbose:
land_pct = float(np.mean(elevation >= sea_level)) * 100
print(f" {body_id}: {GRID_W}x{GRID_H} grid, sea_level={sea_level:.3f}, "
f"land={land_pct:.1f}%, blob={len(blob)} bytes")
if not dry_run: if not dry_run:
conn.execute( # Clean color relief (no painted features — cascade/Atlas overlay those).
"""INSERT INTO atlas_body_heightmaps relief = render_heightmap(bd, terrain, render_mode="cartographic", chrome=False)
(body_id, width, height, data, sea_level, imported_at) relief.save(body_dir / "reliefmap.png")
VALUES (?, ?, ?, ?, ?, datetime('now')) # Canonical 16-bit grayscale elevation; sea_level rides in a tEXt chunk.
ON CONFLICT(body_id) DO UPDATE SET u16 = (elevation * 65535.0).astype(np.uint16)
width = excluded.width, meta = PngInfo()
height = excluded.height, meta.add_text("sea_level", f"{sea_level:.6f}")
data = excluded.data, Image.fromarray(u16).save(body_dir / "heightmap.png", pnginfo=meta)
sea_level = excluded.sea_level,
imported_at = excluded.imported_at""",
(body_id, GRID_W, GRID_H, blob, sea_level),
)
return {"status": "imported"} return {"status": "baked", "shape": elevation.shape, "sea_level": sea_level}
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser( ap = argparse.ArgumentParser(description="Bake canonical heightmap/reliefmap assets (#963)")
description="Import terrain elevation BLOBs into atlas_body_heightmaps (#906)" ap.add_argument("--db", default=str(DB_PATH))
) ap.add_argument("--body", help="Bake only this body_id")
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db") ap.add_argument("--limit", type=int, help="Bake at most N bodies (smoke test)")
parser.add_argument("--body", help="Process only this body_id") ap.add_argument("--dry-run", action="store_true")
parser.add_argument("--force", action="store_true", ap.add_argument("--skip-rename", action="store_true", help="Skip the legacy rename pass")
help="Re-import even if a row already exists") args = ap.parse_args()
parser.add_argument("--dry-run", action="store_true",
help="Simulate without writing to DB")
parser.add_argument("--verbose", action="store_true",
help="Print per-body detail")
args = parser.parse_args()
db_path = Path(args.db) db_path = Path(args.db)
if not db_path.exists(): if not db_path.exists():
print(f"error: {db_path} not found", file=sys.stderr) print(f"error: {db_path} not found", file=sys.stderr)
sys.exit(1) sys.exit(1)
print(f"\n Heightmap BLOB Import (#906)") print("\n Heightmap bake (#963, D-202 amended)")
print(f" DB: {db_path}")
if args.dry_run: if args.dry_run:
print(f" Mode: DRY RUN (no DB writes)") print(" Mode: DRY RUN (no files written)")
if args.force:
print(f" Force: enabled (will overwrite existing rows)")
print()
# 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)) conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA foreign_keys=ON") rows = conn.execute(
ensure_atlas_schema(conn) "SELECT body_id, terrain_reference FROM bodies "
"WHERE inhabited=1 AND terrain_reference IS NOT NULL AND population>0 "
bodies = query_inhabited_bodies(conn) "AND system_id != 'GJ 0' ORDER BY body_id"
if args.body: ).fetchall()
bodies = [b for b in bodies if b["body_id"] == args.body]
if not bodies:
print(f"error: body '{args.body}' not found or has no terrain_reference",
file=sys.stderr)
conn.close()
sys.exit(1)
print(f" {len(bodies)} inhabited bodies with terrain_reference\n")
t_total = time.time()
n_imported = 0
n_skipped = 0
n_gas = 0
n_errors = 0
for i, body_info in enumerate(bodies):
body_id = body_info["body_id"]
t0 = time.time()
result = import_body(body_info, conn, args.force, args.dry_run, args.verbose)
elapsed = time.time() - t0
status = result["status"]
if status == "imported":
n_imported += 1
print(f" [{i+1}/{len(bodies)}] {body_id:20s} imported ({elapsed:.1f}s)")
elif status == "skipped":
n_skipped += 1
if args.verbose:
print(f" [{i+1}/{len(bodies)}] {body_id:20s} skipped (already imported)")
elif status == "gas_giant":
n_gas += 1
if args.verbose:
print(f" [{i+1}/{len(bodies)}] {body_id:20s} gas giant — no surface")
elif status == "error":
n_errors += 1
print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}")
if not args.dry_run:
conn.commit()
conn.close() conn.close()
if args.body:
rows = [r for r in rows if r[0] == args.body]
if args.limit:
rows = rows[: args.limit]
elapsed_total = time.time() - t_total print(f" Baking {len(rows)} non-Sol inhabited bodies\n")
print(f"\n Done in {elapsed_total:.1f}s")
print(f" imported={n_imported} skipped={n_skipped} "
f"gas_giant={n_gas} errors={n_errors}")
if n_errors > 0: parsed: dict[str, list] = {}
print(f"\n {n_errors} error(s) — check output above", file=sys.stderr) 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__": if __name__ == "__main__":