Files
settled-reach/tooling/domains/atlas/planet/import_heightmaps.py
T
jpmschweitzerandClaude Opus 5.5 76e3f3ec96 feat(assets): biome and terrain as two maps, and the bake that writes them (T-1295)
D-258 rulings 5 and 6: the pre-seed biome input is class ids only, on two
maps, because compute_biome was already computing them apart and throwing
one away. It gave every land pixel its Whittaker (climate) class, then painted
terrain over it (ocean bands, ice, altitude snow, mountain rock, lava and ash,
the dry, lunar and ferric ground).

planet_simulation:
- compute_biome_layers returns (biome, terrain). Each rule declares the layer
  it writes, by RULE not by class: ice from the cold climate box is biome,
  altitude snow is terrain with the lowland biome kept beneath. Rules that
  place life (thermophiles, mats, crust, the oasis rings) write the biome and
  clear the terrain under them.
- 255 means "nothing on this layer", not 0, because class 0 is ocean_deep,
  itself terrain. Ids stay literal.
- compute_biome is now compose_layers() of the pair, so the renderers are
  untouched. Proven byte-identical, dtype included, on 65 real bodies: every
  8th standard-atmosphere body plus every thin, thick, reducing and trace one,
  captured before the edit and compared after it.
- simulate() carries both maps as biome_layer / terrain_layer.

reach atlas planet bake-biome [--body --limit --dry-run --check]:
- Writes biomemap.png + terrainmap.png (8-bit, 1024x512, tEXt: layer, none,
  classes, decision) beside each heightmap. Scope is the heightmap bake set
  with an atmosphere (257 bodies; airless are deferred, per D-258).
- Pre-seed by construction: simulate() is keyed on the body frontmatter's
  seed, and no world seed is accepted anywhere.
- --check re-simulates and compares DECODED PIXELS, never bytes, since PNG
  encoding drifts across Pillow/zlib versions (T-1291) and a check that trips
  on that gets muted. Mutation-proven: one flipped pixel exits 1 and names
  the file; the restored file exits 0.
- import_heightmaps' body lookup is extracted as body_def_for and shared,
  not copied.

Tests (make test-tooling): rock keeps its biome beneath, thin-atmosphere
ground is terrain with only scattered life, water is terrain only, no pixel is
empty on both maps, and stacking equals the rendered grid. Routing rock
through the biome map fails two of them by name. The router drift test covers
the new verb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:55:47 +02:00

182 lines
7.2 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 body_def_for(body_id: str, tref: str, parsed: dict[str, list]) -> dict:
"""The parsed body definition for one baked body.
`tref` is the body's terrain_reference (a path under wiki/star-systems);
`parsed` caches each system's parse, since sibling bodies share it. Raises
on a parse failure or a body missing from its system, so callers report
it per body and carry on.
"""
sys_index = str(REPO_ROOT / Path(tref.split("/bodies/")[0]) / "index.md")
defs = parsed.get(sys_index) or parse_system(sys_index)
parsed[sys_index] = defs
bd = next((d for d in defs if d.get("id") == body_id), None)
if bd is None:
raise LookupError("not in system defs")
return bd
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
try:
bd = body_def_for(body_id, tref, parsed)
except Exception as exc: # noqa: BLE001
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: {exc}")
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()