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>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
"""bake_biome — the pre-seed biome input, baked per body (D-258, T-1295).
|
||||
|
||||
For every in-scope body this writes two 8-bit class-id maps beside its
|
||||
`heightmap.png`, at the same 1024x512:
|
||||
|
||||
biomemap.png the climate and life class, taken before terrain is laid
|
||||
over it, so it covers every land pixel, rock and ice included
|
||||
terrainmap.png the landform or substrate class a terrain rule laid down
|
||||
|
||||
Both use 255 for "nothing on this layer" and literal class ids otherwise
|
||||
(`biomes.toml`); what a class MEANS lives in the class tables, not here.
|
||||
|
||||
PRE-SEED by construction. `simulate()` is keyed on the body frontmatter's own
|
||||
`seed`, and nothing here takes a world seed, so a body bakes to the same maps
|
||||
in every playthrough; the server varies the ground, not the map.
|
||||
|
||||
SCOPE: bodies with an atmosphere, out of the heightmap bake set. Airless bodies
|
||||
are deferred (D-258, 2026-09-25). Gas giants have no surface and are skipped.
|
||||
|
||||
`--check` re-simulates and compares decoded pixels, never file bytes: PNG
|
||||
encoding can drift across Pillow/zlib versions (T-1291) without the map
|
||||
changing, and a check that trips on that would be muted.
|
||||
|
||||
Run through reach:
|
||||
reach atlas planet bake-biome --limit 3 # smoke test
|
||||
reach atlas planet bake-biome # all in-scope bodies (~27 min; --detach)
|
||||
reach atlas planet bake-biome --check # fail if committed maps are stale
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from tooling.core import config, console
|
||||
from tooling.core.errors import ReachError
|
||||
from tooling.domains.atlas.planet.import_heightmaps import body_def_for
|
||||
from tooling.domains.atlas.planet.planet_simulation import LAYER_NONE, simulate
|
||||
|
||||
REPO_ROOT = config.repo_root()
|
||||
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
|
||||
|
||||
BIOME_FILE = "biomemap.png"
|
||||
TERRAIN_FILE = "terrainmap.png"
|
||||
|
||||
# The heightmap bake set, narrowed to bodies with an atmosphere.
|
||||
SCOPE_SQL = (
|
||||
"SELECT body_id, terrain_reference FROM bodies "
|
||||
"WHERE inhabited=1 AND terrain_reference IS NOT NULL AND population>0 "
|
||||
"AND system_id != 'GJ 0' "
|
||||
"AND atmosphere IS NOT NULL AND atmosphere != 'none' "
|
||||
"ORDER BY body_id"
|
||||
)
|
||||
|
||||
|
||||
def in_scope(db_path: Path) -> list[tuple[str, str]]:
|
||||
"""`(body_id, terrain_reference)` for every body this bake covers."""
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
return conn.execute(SCOPE_SQL).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def layers_for(bd: dict) -> tuple[np.ndarray, np.ndarray] | None:
|
||||
"""The two maps for one body, or None for a gas giant (no surface)."""
|
||||
terrain = simulate(bd)
|
||||
if not terrain:
|
||||
return None
|
||||
return terrain["biome_layer"], terrain["terrain_layer"]
|
||||
|
||||
|
||||
def _png(layer: np.ndarray, name: str) -> tuple[Image.Image, PngInfo]:
|
||||
meta = PngInfo()
|
||||
meta.add_text("layer", name)
|
||||
meta.add_text("none", str(LAYER_NONE))
|
||||
meta.add_text("classes", "tooling/domains/atlas/planet/biomes.toml")
|
||||
meta.add_text("decision", "D-258")
|
||||
return Image.fromarray(layer.astype(np.uint8), mode="L"), meta
|
||||
|
||||
|
||||
def write_layers(body_dir: Path, biome: np.ndarray, terrain: np.ndarray) -> None:
|
||||
for layer, name, fname in ((biome, "biome", BIOME_FILE), (terrain, "terrain", TERRAIN_FILE)):
|
||||
img, meta = _png(layer, name)
|
||||
img.save(body_dir / fname, pnginfo=meta, optimize=True)
|
||||
|
||||
|
||||
def stale_layers(body_dir: Path, biome: np.ndarray, terrain: np.ndarray) -> list[str]:
|
||||
"""Which committed maps differ, pixel for pixel, from a fresh simulation."""
|
||||
stale = []
|
||||
for layer, fname in ((biome, BIOME_FILE), (terrain, TERRAIN_FILE)):
|
||||
path = body_dir / fname
|
||||
if not path.exists():
|
||||
stale.append(f"{fname} missing")
|
||||
continue
|
||||
on_disk = np.asarray(Image.open(path))
|
||||
if on_disk.shape != layer.shape or not np.array_equal(on_disk, layer):
|
||||
stale.append(fname)
|
||||
return stale
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
ap = argparse.ArgumentParser(description="Bake biomemap.png + terrainmap.png per body (D-258)")
|
||||
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("--check", action="store_true",
|
||||
help="Write nothing; fail if any committed map differs from a fresh bake")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
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")
|
||||
|
||||
rows = in_scope(db_path)
|
||||
if args.body:
|
||||
rows = [r for r in rows if r[0] == args.body]
|
||||
if not rows:
|
||||
raise ReachError(
|
||||
f"{args.body} is not in the bake scope",
|
||||
fix="the scope is inhabited, populated, non-Sol bodies with an atmosphere; "
|
||||
"reach atlas planet bake-biome --dry-run lists it",
|
||||
)
|
||||
if args.limit:
|
||||
rows = rows[: args.limit]
|
||||
|
||||
mode = "check" if args.check else "dry run" if args.dry_run else "bake"
|
||||
console.event(f"biome bake ({mode}): {len(rows)} bodies")
|
||||
|
||||
parsed: dict[str, list] = {}
|
||||
n_done = n_gas = 0
|
||||
errors: list[str] = []
|
||||
stale: list[str] = []
|
||||
t0 = time.time()
|
||||
for i, (body_id, tref) in enumerate(rows):
|
||||
try:
|
||||
bd = body_def_for(body_id, tref, parsed)
|
||||
except Exception as exc: # noqa: BLE001 — per-body, reported and counted
|
||||
errors.append(f"{body_id}: {exc}")
|
||||
continue
|
||||
if args.dry_run:
|
||||
n_done += 1
|
||||
continue
|
||||
layers = layers_for(bd)
|
||||
if layers is None:
|
||||
n_gas += 1
|
||||
continue
|
||||
body_dir = REPO_ROOT / Path(tref).parent
|
||||
if args.check:
|
||||
diff = stale_layers(body_dir, *layers)
|
||||
if diff:
|
||||
stale.append(f"{body_id}: {', '.join(diff)}")
|
||||
else:
|
||||
write_layers(body_dir, *layers)
|
||||
n_done += 1
|
||||
if (i + 1) % 25 == 0:
|
||||
console.event(f" [{i + 1}/{len(rows)}] {body_id}")
|
||||
|
||||
summary = (f"{n_done} bodies, {n_gas} gas giants skipped, {len(errors)} errors "
|
||||
f"in {time.time() - t0:.0f}s")
|
||||
for line in errors:
|
||||
console.event(f" ERROR {line}", level="error")
|
||||
if errors:
|
||||
raise ReachError(
|
||||
f"biome bake: {summary}",
|
||||
fix="each ERROR line names the body; its system page's Celestial Bodies table "
|
||||
"is what body_definition_parser reads",
|
||||
)
|
||||
if stale:
|
||||
for line in stale:
|
||||
console.event(f" STALE {line}", level="error")
|
||||
raise ReachError(
|
||||
f"{len(stale)} bodies' biome maps are stale",
|
||||
fix="reach atlas planet bake-biome, then stage the maps under wiki/star-systems",
|
||||
)
|
||||
console.verdict(f"biome bake ({mode}): {summary}")
|
||||
@@ -67,6 +67,23 @@ def rename_legacy_heightmaps(dry_run: bool) -> int:
|
||||
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:
|
||||
@@ -134,17 +151,10 @@ def main(argv: list[str] | None = None) -> None:
|
||||
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
|
||||
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 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")
|
||||
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: {exc}")
|
||||
n_err += 1
|
||||
continue
|
||||
ts = time.time()
|
||||
|
||||
@@ -11,7 +11,9 @@ Output terrain dict:
|
||||
"elevation": float32 (H, W) [0, 1] normalised elevation
|
||||
"temperature": float32 (H, W) [0, 1] 0=coldest, 1=hottest
|
||||
"moisture": float32 (H, W) [0, 1] 0=driest, 1=wettest
|
||||
"biome": int8 (H, W) biome class index
|
||||
"biome": int8 (H, W) biome class index (composed)
|
||||
"biome_layer": uint8 (H, W) climate/life class, 255 = none
|
||||
"terrain_layer": uint8 (H, W) landform/substrate class, 255 = none
|
||||
"surface_water": bool (H, W) ocean/lake mask
|
||||
"hillshade": float32 (H, W) [0, 1] lighting from slope+aspect
|
||||
"sea_level": float elevation threshold
|
||||
@@ -617,11 +619,54 @@ def compute_hillshade(elevation,
|
||||
# WHITTAKER_TABLE, EXOTIC_CLASSES loaded from biomes.toml via biome_config
|
||||
|
||||
|
||||
# "Nothing on this layer" in the biome and terrain maps. Not 0: class 0 is
|
||||
# ocean_deep, itself a terrain class, and every id stays literal (D-258).
|
||||
LAYER_NONE = 255
|
||||
|
||||
|
||||
def compose_layers(biome, terrain):
|
||||
"""The single class grid the renderers draw: terrain where there is any,
|
||||
the biome beneath it everywhere else."""
|
||||
return np.where(terrain != LAYER_NONE, terrain, biome).astype(np.int8)
|
||||
|
||||
|
||||
def compute_biome(body_def, elevation, sea_level, surface_water,
|
||||
temperature, moisture):
|
||||
H, W = elevation.shape
|
||||
biome = np.zeros((H, W), dtype=np.int8)
|
||||
land = ~surface_water
|
||||
"""The composed class grid — what the reliefmap and globe renders colour."""
|
||||
return compose_layers(*compute_biome_layers(
|
||||
body_def, elevation, sea_level, surface_water, temperature, moisture))
|
||||
|
||||
|
||||
def compute_biome_layers(body_def, elevation, sea_level, surface_water,
|
||||
temperature, moisture):
|
||||
"""Biome and terrain as two maps (D-258, 2026-09-25 rulings 5 and 6).
|
||||
|
||||
The biome map is the climate and life class; the terrain map is the
|
||||
landform or substrate a terrain rule laid over it. Each rule below writes
|
||||
the layer it belongs to, by RULE rather than by class: ice from the cold
|
||||
climate box is biome, snow on a tropical summit is terrain with the tropical
|
||||
biome kept beneath it. A biome rule that lands on terrain (a crust of life
|
||||
on bare rock, a shrub patch on a geothermal spot) replaces the terrain there,
|
||||
exactly as it replaced it in the single grid, so compose_layers() of the
|
||||
result is byte-identical to what compute_biome returned before the split.
|
||||
|
||||
Both maps use LAYER_NONE for "nothing on this layer": water and
|
||||
thin-atmosphere ground carry no biome, most land carries no terrain.
|
||||
"""
|
||||
H, W = elevation.shape
|
||||
biome = np.full((H, W), LAYER_NONE, dtype=np.uint8)
|
||||
terrain = np.full((H, W), LAYER_NONE, dtype=np.uint8)
|
||||
land = ~surface_water
|
||||
|
||||
def paint_terrain(mask, cls):
|
||||
terrain[mask] = cls
|
||||
|
||||
def paint_biome(mask, cls):
|
||||
biome[mask] = cls
|
||||
terrain[mask] = LAYER_NONE
|
||||
|
||||
def composed():
|
||||
return np.where(terrain != LAYER_NONE, terrain, biome)
|
||||
|
||||
atmo = body_def["physical"]["atmosphere"]
|
||||
|
||||
@@ -669,7 +714,8 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
|
||||
cf[(en > 0.70) & (tf < 273)] = 17 # high + cold = ice cap
|
||||
cf[(en < 0.20) & (tf >= 260)] = 27 # low elevation = dust plain
|
||||
|
||||
biome[land] = cf
|
||||
# Bare ground, no life: terrain, with no biome beneath it.
|
||||
paint_terrain(land, cf)
|
||||
else:
|
||||
# --- Standard Whittaker lookup for breathable/toxic atmospheres ---
|
||||
tf = temperature[land].ravel()
|
||||
@@ -681,14 +727,14 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
|
||||
mask = (tf >= tlo) & (tf <= thi) & (mf >= mlo) & (mf <= mhi)
|
||||
cf[mask] = cls
|
||||
|
||||
biome[land] = cf
|
||||
biome[land] = cf # the climate class — the biome map's whole reason to exist
|
||||
|
||||
# Ocean depth bands
|
||||
if surface_water.any():
|
||||
depth = np.clip((sea_level - elevation) / (sea_level + 1e-9), 0, 1)
|
||||
biome[surface_water & (depth < 0.15)] = 2
|
||||
biome[surface_water & (depth >= 0.15) & (depth < 0.50)] = 1
|
||||
biome[surface_water & (depth >= 0.50)] = 0
|
||||
paint_terrain(surface_water & (depth < 0.15), 2)
|
||||
paint_terrain(surface_water & (depth >= 0.15) & (depth < 0.50), 1)
|
||||
paint_terrain(surface_water & (depth >= 0.50), 0)
|
||||
|
||||
# Frozen ocean — override ocean biome with ice shelf (class 26).
|
||||
# Distinct from land ice (17) — slightly different appearance,
|
||||
@@ -701,11 +747,13 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
|
||||
gain=0.5, base_freq=3.0) * 2.0 - 1.0
|
||||
freeze_threshold = 271.0 + ice_noise * 8.0 # ±8K variation
|
||||
frozen_ocean = surface_water & (temperature < freeze_threshold)
|
||||
biome[frozen_ocean] = 26
|
||||
paint_terrain(frozen_ocean, 26)
|
||||
|
||||
# Very cold override — only on worlds with atmosphere (ice needs deposition)
|
||||
# Very cold override — only on worlds with atmosphere (ice needs deposition).
|
||||
# Biome, not terrain: it is the climate's own verdict, the same class the
|
||||
# Whittaker ice box gives, so it carries that climate.
|
||||
if atmo not in ("none",):
|
||||
biome[(temperature < 243.0) & land] = 17 # below -30C → ice
|
||||
paint_biome((temperature < 243.0) & land, 17) # below -30C → ice
|
||||
|
||||
# Elevation overrides — mountain rock and permanent snow.
|
||||
# Only apply snow on worlds with atmosphere (ice needs deposition).
|
||||
@@ -715,7 +763,8 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
|
||||
hydro_here = body_def.get("environment", {}).get("hydrosphere", "none")
|
||||
has_ice_deposition = atmo not in ("none",) and hydro_here not in ("none", "subsurface")
|
||||
if has_ice_deposition:
|
||||
biome[land & (elev_norm > 0.85)] = 17
|
||||
# Altitude snow is terrain: a glacier with the lowland biome beneath.
|
||||
paint_terrain(land & (elev_norm > 0.85), 17)
|
||||
# `temperature` is absolute kelvin here, but the two rules below were
|
||||
# written against a 0-1 scale and compared kelvin to 0.35 from the first
|
||||
# commit: mountain rock could never fire, and the sulfuric test was always
|
||||
@@ -724,7 +773,7 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
|
||||
# that is what they now compare against. A world with no range reads 0.
|
||||
t_span = float(temperature.max() - temperature.min())
|
||||
t_norm = (temperature - temperature.min()) / t_span if t_span > 0 else np.zeros_like(temperature)
|
||||
biome[land & (elev_norm > 0.65) & (t_norm < 0.35)] = 18
|
||||
paint_terrain(land & (elev_norm > 0.65) & (t_norm < 0.35), 18)
|
||||
|
||||
# ── Modifier stack ─────────────────────────────────────────────────────
|
||||
env = body_def.get("environment", {})
|
||||
@@ -737,30 +786,36 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
|
||||
|
||||
# Geothermal: volcanic worlds get lava/ash at high elevations
|
||||
if geothermal in ("extreme", "high") and planet_class == "volcanic":
|
||||
biome[land & (elev_norm > 0.75)] = EXOTIC_CLASSES["lava_field"]
|
||||
biome[land & (elev_norm > 0.45) & (elev_norm <= 0.75)] = EXOTIC_CLASSES["ash_field"]
|
||||
paint_terrain(land & (elev_norm > 0.75), EXOTIC_CLASSES["lava_field"])
|
||||
paint_terrain(land & (elev_norm > 0.45) & (elev_norm <= 0.75), EXOTIC_CLASSES["ash_field"])
|
||||
|
||||
# The four rules below place LIFE (thermophiles, mats, crusts, scrub), so
|
||||
# they write the biome map and replace any terrain under them.
|
||||
|
||||
# Thermophilic fields near heat vents on any high-geothermal world
|
||||
if geothermal in ("extreme", "high") and not chemosyn:
|
||||
hot = (temperature > 303.0) & land & (elev_norm < 0.45)
|
||||
biome[hot] = EXOTIC_CLASSES["thermophilic_field"]
|
||||
paint_biome(hot, EXOTIC_CLASSES["thermophilic_field"])
|
||||
|
||||
# Chemosynthetic worlds (Europa-type): cold surface, geothermal warmth
|
||||
if chemosyn:
|
||||
geo_warm = (temperature > 263.0) & (temperature < 293.0) & land
|
||||
biome[geo_warm] = EXOTIC_CLASSES["chemosynthetic_mat"]
|
||||
paint_biome(geo_warm, EXOTIC_CLASSES["chemosynthetic_mat"])
|
||||
|
||||
# UV radiation: cryptobiotic crust on exposed terrain with thin/no atmo
|
||||
if uv_index in ("extreme", "high") and atmo in ("none", "thin"):
|
||||
shown = composed()
|
||||
exposed = (land & (elev_norm > 0.15) & (elev_norm < 0.65)
|
||||
& (moisture < 0.30)
|
||||
& (biome != 17) & (biome != 18) & (biome != 19))
|
||||
biome[exposed] = EXOTIC_CLASSES["cryptobiotic_crust"]
|
||||
& (shown != 17) & (shown != 18) & (shown != 19))
|
||||
paint_biome(exposed, EXOTIC_CLASSES["cryptobiotic_crust"])
|
||||
|
||||
# Sulfuric substrate: scrub on volcanic mid-elevations
|
||||
# Sulfuric substrate: scrub on volcanic mid-elevations. Unreachable as
|
||||
# written — it converts rock (18) only below elev_norm 0.65, and rock exists
|
||||
# only above it (T-1295; left for the balancing pass).
|
||||
if substrate == "sulfuric":
|
||||
scrub = land & (elev_norm > 0.25) & (elev_norm < 0.65) & (t_norm > 0.35)
|
||||
biome[scrub & (biome == 18)] = EXOTIC_CLASSES["sulfuric_scrub"]
|
||||
paint_biome(scrub & (composed() == 18), EXOTIC_CLASSES["sulfuric_scrub"])
|
||||
|
||||
# ── Anomaly scatter ─────────────────────────────────────────────────
|
||||
# Sparse micro-features that break biome uniformity and tell stories.
|
||||
@@ -775,7 +830,7 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
|
||||
scatter_mask = (scatter_noise > 0.72) & land
|
||||
|
||||
if scatter_mask.any():
|
||||
b_local = biome[scatter_mask]
|
||||
b_local = composed()[scatter_mask]
|
||||
t_local = temperature[scatter_mask]
|
||||
m_local = moisture[scatter_mask]
|
||||
e_local = elev_norm[scatter_mask]
|
||||
@@ -805,12 +860,20 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
|
||||
lava_mask = np.isin(b_local, [19, 25])
|
||||
new_b[lava_mask & (t_local < 310) & (m_local > 0.30)] = 24 # lithic pioneer
|
||||
|
||||
biome[scatter_mask] = new_b
|
||||
# Route each repainted cell to its layer: vents, ash and oasis
|
||||
# water are ground; savanna, shrubland and pioneer growth are life.
|
||||
changed = new_b != b_local
|
||||
rows, cols = (a[changed] for a in np.nonzero(scatter_mask))
|
||||
vals = new_b[changed]
|
||||
ground = np.isin(vals, (19, 25, 2))
|
||||
terrain[rows[ground], cols[ground]] = vals[ground]
|
||||
biome[rows[~ground], cols[~ground]] = vals[~ground]
|
||||
terrain[rows[~ground], cols[~ground]] = LAYER_NONE
|
||||
|
||||
# Vegetation ring around oasis lakes: dilate water cells from the
|
||||
# scatter pass and assign graduated vegetation to the ring.
|
||||
# water → coast vegetation → savanna/shrub → original biome
|
||||
oasis_water = (biome == 2) & land # scattered lake cells on land
|
||||
oasis_water = (composed() == 2) & land # scattered lake cells on land
|
||||
if oasis_water.any():
|
||||
from scipy.ndimage import binary_dilation
|
||||
# Pixel-unit iteration counts scale by GRID_W/512 (module
|
||||
@@ -833,11 +896,11 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
|
||||
& land
|
||||
)
|
||||
# Inner ring: lush vegetation (coast/lowland green)
|
||||
biome[ring1] = 4 # lowland
|
||||
paint_biome(ring1, 4) # lowland
|
||||
# Outer ring: transitional (savanna/shrub)
|
||||
biome[ring2] = 12 # shrubland
|
||||
paint_biome(ring2, 12) # shrubland
|
||||
|
||||
return biome
|
||||
return biome, terrain
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -874,8 +937,9 @@ def simulate(body_def: dict) -> dict:
|
||||
|
||||
hillshade = compute_hillshade(elevation)
|
||||
|
||||
biome = compute_biome(
|
||||
biome_layer, terrain_layer = compute_biome_layers(
|
||||
body_def, elevation, sea_level, surface_water, temperature, moisture)
|
||||
biome = compose_layers(biome_layer, terrain_layer)
|
||||
|
||||
# Normalise temperature to [0,1] for renderer display — biome already computed
|
||||
t_min, t_max = temperature.min(), temperature.max()
|
||||
@@ -885,7 +949,9 @@ def simulate(body_def: dict) -> dict:
|
||||
"elevation": elevation,
|
||||
"temperature": temperature_norm, # normalised [0,1] for renderer
|
||||
"moisture": moisture,
|
||||
"biome": biome,
|
||||
"biome": biome, # composed — what the renders colour
|
||||
"biome_layer": biome_layer, # D-258: the baked biomemap (LAYER_NONE = none)
|
||||
"terrain_layer": terrain_layer, # D-258: the baked terrainmap
|
||||
"surface_water": surface_water,
|
||||
"hillshade": hillshade,
|
||||
"sea_level": sea_level,
|
||||
|
||||
@@ -162,6 +162,28 @@ def import_heightmaps(
|
||||
impl.main(_flags(db=db, body=body, limit=limit, dry_run=dry_run, skip_rename=skip_rename))
|
||||
|
||||
|
||||
@app.command("bake-biome")
|
||||
@command
|
||||
def bake_biome(
|
||||
db: Path = typer.Option(None, "--db", help="systems.db path (reads the body scope)."),
|
||||
body: str = typer.Option(None, "--body", help="Bake only this body."),
|
||||
limit: int = typer.Option(None, "--limit", help="Stop after N bodies."),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Count the scope, write nothing."),
|
||||
check: bool = typer.Option(
|
||||
False, "--check", help="Write nothing; fail if a committed map differs from a fresh bake."
|
||||
),
|
||||
) -> None:
|
||||
"""Bake biomemap.png + terrainmap.png beside each body's heightmap (D-258).
|
||||
|
||||
Pre-seed: keyed on the body's own seed, never the world's. Scope is the
|
||||
heightmap bake set with an atmosphere. A full bake is ~27 minutes;
|
||||
`--detach` and tail it. Stage the maps under wiki/star-systems after.
|
||||
"""
|
||||
from tooling.domains.atlas.planet import bake_biome as impl
|
||||
|
||||
impl.main(_flags(db=db, body=body, limit=limit, dry_run=dry_run, check=check))
|
||||
|
||||
|
||||
@app.command("import-provinces")
|
||||
@command
|
||||
def import_provinces(
|
||||
|
||||
@@ -28,7 +28,10 @@ import numpy as np # noqa: E402
|
||||
from tooling.domains.atlas.planet.planet_simulation import ( # noqa: E402
|
||||
GRID_H,
|
||||
GRID_W,
|
||||
LAYER_NONE,
|
||||
compose_layers,
|
||||
compute_biome,
|
||||
compute_biome_layers,
|
||||
)
|
||||
|
||||
MOUNTAIN_ROCK = 18
|
||||
@@ -78,5 +81,66 @@ class MountainRockTests(unittest.TestCase):
|
||||
self.assertEqual(int((low == MOUNTAIN_ROCK).sum()), 0)
|
||||
|
||||
|
||||
class LayerSplitTests(unittest.TestCase):
|
||||
"""D-258 ruling 6: biome and terrain are two maps (T-1295)."""
|
||||
|
||||
def _layers(self, atmosphere: str, water_rows: int = 0):
|
||||
elev = np.full((GRID_H, GRID_W), 0.3, dtype=np.float32)
|
||||
elev[:, : GRID_W // 2] = 0.95 # high west, as in MountainRockTests
|
||||
water = np.zeros((GRID_H, GRID_W), dtype=bool)
|
||||
if water_rows:
|
||||
water[-water_rows:, :] = True # a southern sea
|
||||
elev[-water_rows:, :] = 0.05
|
||||
temp = np.tile(np.linspace(275.0, 305.0, GRID_W, dtype=np.float32), (GRID_H, 1))
|
||||
moisture = np.full((GRID_H, GRID_W), 0.5, dtype=np.float32)
|
||||
body = _body()
|
||||
body["physical"]["atmosphere"] = atmosphere
|
||||
body["environment"]["hydrosphere"] = "liquid_water" if water_rows else "none"
|
||||
biome, terrain = compute_biome_layers(body, elev, SEA_LEVEL, water, temp, moisture)
|
||||
return biome, terrain, water
|
||||
|
||||
def test_rock_keeps_its_biome_beneath(self):
|
||||
# The point of the split: the climate class under a mountain survives.
|
||||
biome, terrain, _ = self._layers("standard")
|
||||
rock = terrain == MOUNTAIN_ROCK
|
||||
self.assertTrue(rock.any())
|
||||
self.assertFalse((biome[rock] == LAYER_NONE).any(), "rock with no biome beneath")
|
||||
|
||||
def test_thin_atmosphere_is_bare_ground_with_scattered_life(self):
|
||||
# Thin worlds skip the Whittaker table, so their ground is terrain. The
|
||||
# only life is what the anomaly scatter places (it skips airless worlds
|
||||
# only): oasis savanna, shrubs, pioneers. So the biome map is sparse and
|
||||
# holds nothing but those classes.
|
||||
biome, terrain, _ = self._layers("thin")
|
||||
placed = biome != LAYER_NONE
|
||||
self.assertLess(float(placed.mean()), 0.10, "thin world carries a climate biome")
|
||||
self.assertTrue(np.isin(biome[placed], (4, 7, 12, 24)).all(), set(np.unique(biome[placed])))
|
||||
self.assertFalse((terrain[~placed] == LAYER_NONE).any())
|
||||
|
||||
def test_water_is_terrain_only(self):
|
||||
biome, terrain, water = self._layers("standard", water_rows=64)
|
||||
self.assertTrue((biome[water] == LAYER_NONE).all())
|
||||
self.assertTrue(np.isin(terrain[water], (0, 1, 2, 26)).all())
|
||||
|
||||
def test_no_pixel_is_empty_on_both_maps(self):
|
||||
for atmo, rows in (("standard", 0), ("standard", 64), ("thin", 0)):
|
||||
biome, terrain, _ = self._layers(atmo, water_rows=rows)
|
||||
both = (biome == LAYER_NONE) & (terrain == LAYER_NONE)
|
||||
self.assertFalse(both.any(), f"{atmo}/{rows}: {int(both.sum())} px on neither map")
|
||||
|
||||
def test_stacking_the_maps_is_the_rendered_grid(self):
|
||||
biome, terrain, water = self._layers("standard", water_rows=64)
|
||||
composed = compose_layers(biome, terrain)
|
||||
elev = np.full((GRID_H, GRID_W), 0.3, dtype=np.float32)
|
||||
elev[:, : GRID_W // 2] = 0.95
|
||||
elev[-64:, :] = 0.05
|
||||
temp = np.tile(np.linspace(275.0, 305.0, GRID_W, dtype=np.float32), (GRID_H, 1))
|
||||
body = _body()
|
||||
body["environment"]["hydrosphere"] = "liquid_water"
|
||||
grid = compute_biome(body, elev, SEA_LEVEL, water, temp,
|
||||
np.full((GRID_H, GRID_W), 0.5, dtype=np.float32))
|
||||
self.assertTrue(np.array_equal(composed, grid))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -38,6 +38,7 @@ IMPLEMENTATIONS = {
|
||||
"batch": ("batch", []),
|
||||
"scaffold": ("scaffold_bodies", ["index.json"]),
|
||||
"import-heightmaps": ("import_heightmaps", []),
|
||||
"bake-biome": ("bake_biome", []),
|
||||
"import-provinces": ("import_province_boundaries", []),
|
||||
"terrain-reference": ("populate_terrain_reference", []),
|
||||
"sol-import": ("sol_import", []),
|
||||
|
||||
Reference in New Issue
Block a user