The 30-file tree moves under atlas as its third rung (D-243), ten verbs fronting it. Each verb restates its module's options so `--help` describes something; tooling/test_planet_router.py hands every declared option to the module's own argparse and fails on drift, and now runs in make test-tooling. The 2026-09-02 half of this move had converted the top-level imports and the repo roots. Finishing it found what the half-move left: - Lazy in-function imports, and all of sol_data/, still named siblings bare. They resolved only through sys.path.insert hacks, so under reach the first globe render in generate, batch or sol-import would have raised ModuleNotFoundError. Qualified; the hacks are gone. - 247 print() calls and a stdout progress writer that fired once per 8 KB block. Report verbs (audit, quality) write through console.out, progress through console.event, and download progress is throttled to 10% steps so a job log is not tens of thousands of lines. - Every error exit raises ReachError with a fix. Two checks that could not fail: - batch --verify-determinism printed a warning and exited 0 on a mismatch. - import-provinces exited 0 with errors > 0. Both now raise. The 271-body bake is only safe to re-run because the first one holds. sol-import --body is action="append" in the module but the router took one value, so --body GJ0d --body GJ0e kept one. Now repeatable, and _flags repeats list options. test_conformance walked one level, so a nested group was reported as a verb missing @command and its ten verbs were never checked. It recurses now; proven by stripping @command from `planet quality` and watching it fail. Stray PNGs from the 2026-09-03 runaway router-test run are parked in .cache/t1288-stray-pngs/, not committed. Their reliefmaps differ from HEAD while the heightmap regenerated byte-identical — filed as T-1291. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
6.9 KiB
Python
172 lines
6.9 KiB
Python
#!/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):
|
||
|
||
reach atlas planet import-heightmaps # full bake
|
||
reach atlas planet import-heightmaps --limit 3 # smoke test
|
||
reach atlas planet import-heightmaps --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
|
||
|
||
from tooling.core import config, console
|
||
from tooling.core.errors import ReachError
|
||
|
||
import numpy as np
|
||
from PIL import Image
|
||
from PIL.PngImagePlugin import PngInfo
|
||
|
||
PLANET_DIR = Path(__file__).resolve().parent
|
||
REPO_ROOT = config.repo_root()
|
||
|
||
from tooling.domains.atlas.planet.body_definition_parser import parse_system # noqa: E402
|
||
from tooling.domains.atlas.planet.planet_simulation import simulate # noqa: E402
|
||
from tooling.domains.atlas.planet.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(argv: list[str] | None = None) -> 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():
|
||
raise ReachError(f"{db_path} not found", fix="make regen-db, or point --db at an existing systems.db")
|
||
|
||
console.event(" Heightmap bake (#963, D-202 amended)")
|
||
if args.dry_run:
|
||
console.event(" 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)
|
||
console.event(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]
|
||
|
||
console.event(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
|
||
console.event(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:
|
||
console.event(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:
|
||
console.event(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
|
||
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: {res.get('message','')}")
|
||
|
||
console.event(f" Done in {time.time()-t0:.0f}s — baked={n_baked} gas_giant={n_gas} errors={n_err}")
|
||
console.event(" 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()
|