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>
182 lines
7.2 KiB
Python
182 lines
7.2 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 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()
|