Move the shared atlas-DB utilities (schema application, inhabited-body query, body-def loader, grid constants) out of the soon-to-be-retired generate_atlas.py into a dedicated atlas_common.py with no dependency on geometry-production code. Repoint the surviving build-time importers (import_heightmaps, import_province_boundaries) at the new module. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
220 lines
7.0 KiB
Python
220 lines
7.0 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
import_heightmaps.py — Import terrain elevation grids into atlas_body_heightmaps.
|
||
|
||
For each inhabited body with a terrain_reference, simulates the terrain via
|
||
planet_simulation.simulate() and stores the float32 LE elevation BLOB plus
|
||
sea_level metadata in atlas_body_heightmaps (#906, D-202).
|
||
|
||
The BLOB format matches the Rust loader spec (D-202):
|
||
- float32 little-endian, row-major
|
||
- width × height values, each in [0.0, 1.0]
|
||
- width = GRID_W (512), height = GRID_H (256)
|
||
|
||
Incremental: bodies that already have a row in atlas_body_heightmaps are
|
||
skipped unless --force is passed.
|
||
|
||
Usage:
|
||
tooling/planet-gen/import_heightmaps.py
|
||
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:
|
||
0 completed (possibly with skipped or errored bodies)
|
||
1 fatal error (missing DB, schema error)
|
||
"""
|
||
|
||
import argparse
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
TOOLING_DIR = Path(__file__).resolve().parent
|
||
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
||
|
||
_venv_python = REPO_ROOT / ".venv" / "bin" / "python"
|
||
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
|
||
import os
|
||
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
|
||
|
||
import numpy as np
|
||
import sqlite3
|
||
|
||
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:
|
||
"""Convert a float32 elevation grid to a little-endian BLOB."""
|
||
arr = elevation.astype("<f4") # float32 LE, explicit
|
||
assert arr.shape == (GRID_H, GRID_W), (
|
||
f"elevation shape {arr.shape} does not match expected ({GRID_H}, {GRID_W})"
|
||
)
|
||
return arr.tobytes()
|
||
|
||
|
||
def import_body(
|
||
body_info: dict,
|
||
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:
|
||
terrain = simulate(bd)
|
||
except Exception as exc:
|
||
return {"status": "error", "message": f"simulate() failed: {exc}"}
|
||
|
||
if not terrain:
|
||
return {"status": "gas_giant"}
|
||
|
||
elevation = terrain.get("elevation")
|
||
if elevation is None:
|
||
return {"status": "error", "message": "terrain dict missing 'elevation' key"}
|
||
|
||
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:
|
||
conn.execute(
|
||
"""INSERT INTO atlas_body_heightmaps
|
||
(body_id, width, height, data, sea_level, imported_at)
|
||
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||
ON CONFLICT(body_id) DO UPDATE SET
|
||
width = excluded.width,
|
||
height = excluded.height,
|
||
data = excluded.data,
|
||
sea_level = excluded.sea_level,
|
||
imported_at = excluded.imported_at""",
|
||
(body_id, GRID_W, GRID_H, blob, sea_level),
|
||
)
|
||
|
||
return {"status": "imported"}
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(
|
||
description="Import terrain elevation BLOBs into atlas_body_heightmaps (#906)"
|
||
)
|
||
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
|
||
parser.add_argument("--body", help="Process only this body_id")
|
||
parser.add_argument("--force", action="store_true",
|
||
help="Re-import even if a row already exists")
|
||
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)
|
||
if not db_path.exists():
|
||
print(f"error: {db_path} not found", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
print(f"\n Heightmap BLOB Import (#906)")
|
||
print(f" DB: {db_path}")
|
||
if args.dry_run:
|
||
print(f" Mode: DRY RUN (no DB writes)")
|
||
if args.force:
|
||
print(f" Force: enabled (will overwrite existing rows)")
|
||
print()
|
||
|
||
conn = sqlite3.connect(str(db_path))
|
||
conn.execute("PRAGMA foreign_keys=ON")
|
||
ensure_atlas_schema(conn)
|
||
|
||
bodies = query_inhabited_bodies(conn)
|
||
if args.body:
|
||
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()
|
||
|
||
elapsed_total = time.time() - t_total
|
||
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:
|
||
print(f"\n {n_errors} error(s) — check output above", file=sys.stderr)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|