Files
settled-reach/tooling/domains/atlas/planet/import_heightmaps.py
T
jpmschweitzerandClaude Opus 5.5 668772075c refactor(tooling): T-1288 — planet-gen becomes reach atlas planet
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>
2026-09-23 16:08:02 +02:00

172 lines
6.9 KiB
Python
Raw 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):
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()