Files
settled-reach/spikes/heightmap-pipeline/generate_kallast.py
T
jpmschweitzerandClaude Sonnet 4.6 f64ee9dded feat(assets): annotated heightmap pipeline — Kallast spike #778
Validates full heightmap pipeline: pyplatec tectonics → erosion →
dynamic sea level → terrain classification (13 classes) → D8 river
network → settlement placement → geographic PNG + settlement JSON.

Key technical decisions:
- Dynamic sea level via np.percentile (pyplatec output is right-skewed;
  fixed fraction gives ~0.2% land, not 40%)
- Terrain classes as fractions of land_range (not fixed offsets)
- Two-layer model: geographic PNG + human-layer JSON sidecar
- Rivers painted AFTER LANCZOS upscale via NEAREST neighbor mask
  (painting before blurs rivers into invisibility)
- grain_belt removed — reclassified as grassland (natural terrain)
- Irrigation overlay removed — human activity, lives in JSON sidecar

Output: 4096×2048px (1024×512 simulation grid, 5.2s total runtime).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 08:19:23 +02:00

569 lines
25 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
"""
Heightmap Spike: Kallast (GJ144d)
Produces one annotated heightmap for Kallast — Ran system's inner habitable
world. Wave 1 agricultural planet, breathable atmosphere, 0.95g, temperate.
Pipeline:
1. Tectonic simulation (pyplatec) → elevation grid
2. Hydraulic erosion (numpy/scipy) → soften ridges, carve valleys
3. Climate pass → moisture/temperature from latitude + elevation
4. Terrain classification → biome zones
5. Hydrology → flow accumulation → river network
6. Settlement placement → cities along rivers + fertile plains
7. Road network → minimum spanning connections between major cities
8. Annotated render → PNG export
Usage:
python3 generate_kallast.py [--output path] [--seed N] [--fast]
Outputs:
kallast_heightmap.png — annotated world map
kallast_terrain.npy — raw terrain grid (numpy, for batch reuse)
kallast_rivers.npy — river network mask
kallast_settlements.json — city coordinates and names
"""
import argparse
import json
import math
import random
import time
import sys
import numpy as np
from PIL import Image, ImageDraw, ImageFont
# ─────────────────────────────────────────────────────────────────────────────
# Planet parameters (from wiki/systems.db: GJ144d Kallast)
# ─────────────────────────────────────────────────────────────────────────────
PLANET = {
"name": "Kallast",
"system": "Ran (GJ 144)",
"body_id": "GJ144d",
"gravity": 0.95, # g — affects tectonic force scaling
"atmosphere": "breathable",
"biome_summary": "temperate",
"hydrosphere": "ocean",
"population": 2_000_000_000,
"settlement_wave": 1,
"settlement_age_years": 580,
"industrial": "Agricultural_Syndic",
# Tectonic profile: Earth-like, high activity (mature world, well-eroded)
"plate_count": 10,
"land_fraction": 0.40, # 40% land coverage — continental grain belt world
# Note: SEA_LEVEL is computed dynamically from land_fraction after
# tectonic simulation, since pyplatec produces a skewed distribution
# that does not map linearly to target coverage percentages.
}
# ─────────────────────────────────────────────────────────────────────────────
# Grid settings
# ─────────────────────────────────────────────────────────────────────────────
GRID_W = 1024
GRID_H = 512
OUTPUT_W = 4096
OUTPUT_H = 2048
# ─────────────────────────────────────────────────────────────────────────────
# Color palette (Settled Reach visual grammar: muted, earthy, legible)
# ─────────────────────────────────────────────────────────────────────────────
PALETTE = {
"ocean_deep": (18, 32, 58),
"ocean_mid": (28, 52, 90),
"ocean_shallow": (42, 80, 110),
"coast": (80, 105, 75),
"lowland": (95, 115, 65),
"plains": (130, 145, 80),
"grassland": (165, 155, 75), # temperate plains / savanna (amber tone)
"hills": (120, 110, 80),
"forest": (60, 90, 55),
"highland": (110, 100, 90),
"mountain": (140, 130, 120),
"peak": (195, 190, 185),
"snow": (230, 228, 225),
# Annotation colors
"river": (80, 140, 200),
"road_major": (180, 155, 90),
"road_minor": (160, 140, 85),
"city_major": (220, 60, 50),
"city_minor": (200, 110, 60),
"city_label": (240, 235, 220),
"freight_elev": (200, 180, 100), # freight elevator pads
"grid_line": (255, 255, 255),
"title_bg": (15, 18, 25),
"title_text": (200, 208, 224), # insert chrome: #c8d0e0
}
def step(msg: str):
print(f" [{time.strftime('%H:%M:%S')}] {msg}", flush=True)
# ─────────────────────────────────────────────────────────────────────────────
# 1. Tectonic simulation
# ─────────────────────────────────────────────────────────────────────────────
def run_tectonics(seed: int, fast: bool = False) -> np.ndarray:
"""Run pyplatec tectonic simulation. Returns normalised float32 grid."""
step("Running tectonic simulation (pyplatec)…")
import platec
sim_steps = 50 if fast else 200
p = platec.create(
seed,
GRID_W, GRID_H,
sea_level=PLANET["land_fraction"],
erosion_period=60,
folding_ratio=0.02,
aggr_overlap_abs=1_000_000,
aggr_overlap_rel=0.33,
cycle_count=2,
num_plates=PLANET["plate_count"],
)
for _ in range(sim_steps):
platec.step(p)
hmap_raw = platec.get_heightmap(p)
platec.destroy(p)
arr = np.array(hmap_raw, dtype=np.float32).reshape(GRID_H, GRID_W)
# Normalise to [0, 1]
lo, hi = arr.min(), arr.max()
arr = (arr - lo) / (hi - lo + 1e-9)
step(f"Tectonic done. Elevation range: [{lo:.1f}, {hi:.1f}]")
return arr
# ─────────────────────────────────────────────────────────────────────────────
# 2. Hydraulic erosion (simplified — scipy gaussian smoothing on steep slopes)
# ─────────────────────────────────────────────────────────────────────────────
def erode(terrain: np.ndarray, passes: int = 3) -> np.ndarray:
"""Simplified erosion: smooth with slope-weighted kernel."""
step(f"Applying erosion ({passes} passes)…")
from scipy.ndimage import gaussian_filter, uniform_filter
result = terrain.copy()
for i in range(passes):
# Identify steep slopes
gy, gx = np.gradient(result)
slope = np.sqrt(gx**2 + gy**2)
# Smooth strongly on steep areas (erosion), less on plains
smooth = gaussian_filter(result, sigma=1.5)
weight = np.clip(slope * 8, 0, 1)
result = result * (1 - weight * 0.4) + smooth * (weight * 0.4)
return result
# ─────────────────────────────────────────────────────────────────────────────
# 3. Terrain classification
# ─────────────────────────────────────────────────────────────────────────────
SEA_LEVEL = None # set dynamically after tectonic simulation
def compute_sea_level(terrain: np.ndarray, land_fraction: float) -> float:
"""
Compute sea level as the percentile that yields the target land fraction.
pyplatec produces a skewed elevation distribution (most area at low elevation,
peaks only at plate boundaries), so we cannot use a fixed fraction of the
0-1 normalised range.
"""
ocean_fraction = 1.0 - land_fraction
sl = float(np.percentile(terrain, ocean_fraction * 100))
step(f"Sea level computed: {sl:.4f} (target land={land_fraction*100:.0f}%, "
f"actual≈{(terrain >= sl).sum() / terrain.size * 100:.1f}%)")
return sl
def classify(terrain: np.ndarray) -> np.ndarray:
"""
Returns integer class array relative to the dynamic sea level.
Thresholds are expressed as fractions of the land elevation range
(sea_level to max) rather than fixed offsets, so they scale correctly
regardless of the pyplatec output distribution.
0 = ocean_deep (lowest)
1 = ocean_mid
2 = ocean_shallow
3 = coast (just above SL)
4 = lowland
5 = plains
6 = grassland (temperate plains / savanna — natural terrain class)
7 = hills
8 = forest
9 = highland
10 = mountain
11 = peak
12 = snow (highest)
"""
sl = SEA_LEVEL
land_max = terrain.max()
land_range = max(land_max - sl, 1e-6)
c = np.zeros_like(terrain, dtype=np.int8)
# Ocean bands (below sea level)
ocean_range = max(sl - terrain.min(), 1e-6)
c[terrain >= terrain.min()] = 0 # ocean_deep (baseline)
c[terrain >= sl - ocean_range * 0.5] = 1 # ocean_mid
c[terrain >= sl - ocean_range * 0.2] = 2 # ocean_shallow
# Land bands (above sea level, as fraction of land_range)
c[terrain >= sl] = 3 # coast
c[terrain >= sl + land_range * 0.05] = 4 # lowland
c[terrain >= sl + land_range * 0.15] = 5 # plains
c[terrain >= sl + land_range * 0.28] = 6 # grassland
c[terrain >= sl + land_range * 0.42] = 7 # hills
c[terrain >= sl + land_range * 0.53] = 8 # forest
c[terrain >= sl + land_range * 0.63] = 9 # highland
c[terrain >= sl + land_range * 0.74] = 10 # mountain
c[terrain >= sl + land_range * 0.85] = 11 # peak
c[terrain >= sl + land_range * 0.93] = 12 # snow
return c
CLASS_COLORS = [
PALETTE["ocean_deep"],
PALETTE["ocean_mid"],
PALETTE["ocean_shallow"],
PALETTE["coast"],
PALETTE["lowland"],
PALETTE["plains"],
PALETTE["grassland"],
PALETTE["hills"],
PALETTE["forest"],
PALETTE["highland"],
PALETTE["mountain"],
PALETTE["peak"],
PALETTE["snow"],
]
# ─────────────────────────────────────────────────────────────────────────────
# 4. Hydrology — flow accumulation → river network
# ─────────────────────────────────────────────────────────────────────────────
def compute_rivers(terrain: np.ndarray, threshold: int = 30) -> np.ndarray:
"""
Simple D8 flow accumulation. Returns boolean mask of river cells.
Not physically accurate but produces plausible branching networks.
"""
step("Computing river network…")
H, W = terrain.shape
# D8 direction offsets
dirs = [(-1,-1),(-1,0),(-1,1),(0,-1),(0,1),(1,-1),(1,0),(1,1)]
# For each land cell, find steepest descent
flow_acc = np.zeros((H, W), dtype=np.int32)
land = terrain >= SEA_LEVEL
# Simplified: accumulate flow by draining from high to low
# Sort cells by elevation descending
ys, xs = np.where(land)
order = np.argsort(terrain[ys, xs])[::-1]
ys_sorted = ys[order]
xs_sorted = xs[order]
for y, x in zip(ys_sorted, xs_sorted):
flow_acc[y, x] += 1
# Find steepest downslope neighbour
best_drop = 0
best_ny, best_nx = -1, -1
for dy, dx in dirs:
ny, nx = y + dy, x + dx
if 0 <= ny < H and 0 <= nx < W:
drop = terrain[y, x] - terrain[ny, nx]
if drop > best_drop:
best_drop = drop
best_ny, best_nx = ny, nx
if best_ny >= 0:
flow_acc[best_ny, best_nx] += flow_acc[y, x]
rivers = (flow_acc > threshold) & land
step(f"River network: {rivers.sum()} cells above threshold {threshold}")
return rivers
# ─────────────────────────────────────────────────────────────────────────────
# 5. Settlement placement
# ─────────────────────────────────────────────────────────────────────────────
KALLAST_CITIES = [
# Name, (relative grid position), tier (1=capital, 2=regional, 3=node)
# Placed on fertile plains near river confluences.
# Temperate plains world → most cities on the grassland continental shelf.
("Kallast Prime", (0.45, 0.48), 1), # capital, central continent
("Ardenvall", (0.30, 0.40), 2), # western grain province
("Thessmark", (0.60, 0.52), 2), # eastern province
("Coldwater", (0.25, 0.62), 2), # southern coast, fishing + export
("Brightfield", (0.50, 0.36), 2), # northern plains
("Vorn's Crossing", (0.38, 0.55), 3), # river crossing, freight node
("Saltmere", (0.68, 0.42), 3), # coast + processing node
("Kaspel", (0.20, 0.50), 3), # western interior node
("Drenmark", (0.72, 0.58), 3), # southeastern node
("New Farrow", (0.55, 0.64), 3), # southern freight hub
("Ossenfield", (0.42, 0.30), 3), # northern highland approach
("Tyne Station", (0.33, 0.45), 3), # freight elevator ground station
]
# Freight elevator locations (visible from orbit per wiki)
FREIGHT_ELEVATORS = [
("Kallast Anchor", (0.45, 0.46)),
("Ardenvall Lift", (0.29, 0.38)),
("Thessmark Riser", (0.61, 0.50)),
]
def place_settlements(terrain: np.ndarray, cities: list) -> list:
"""
Snap city positions to nearest suitable terrain cell.
Suitable = plains or grassland class, preferably near river.
Returns list of (name, grid_y, grid_x, tier) tuples.
"""
step("Placing settlements…")
classified = classify(terrain)
# Debug: show land cell distribution
for cls_id in range(13):
n = (classified == cls_id).sum()
if n > 0:
step(f" class {cls_id}: {n} cells")
H, W = terrain.shape
placed = []
for name, (rx, ry), tier in cities:
cx = int(rx * W)
cy = int(ry * H)
# Search in expanding radius for valid terrain (up to 60 cells)
best_y, best_x = cy, cx
best_score = -1
for radius in [20, 40, 60, 80]:
for dy in range(-radius, radius + 1):
for dx in range(-radius, radius + 1):
ty, tx = cy + dy, cx + dx
if 0 <= ty < H and 0 <= tx < W:
c = classified[ty, tx]
# Score: higher for plains/grain_belt, acceptable for coast/lowland
score = 0
if c in (5, 6): # plains/grassland (ideal settlement terrain)
score = 20 - (abs(dy) + abs(dx)) * 0.2
elif c == 4: # lowland
score = 12 - (abs(dy) + abs(dx)) * 0.2
elif c == 3: # coast (ports, export hubs)
score = 8 - (abs(dy) + abs(dx)) * 0.2
elif c == 7: # hills (defensible / highland cities)
score = 5 - (abs(dy) + abs(dx)) * 0.2
if score > best_score:
best_score = score
best_y, best_x = ty, tx
if best_score > 0:
break # found valid terrain at this radius, stop expanding
placed.append((name, best_y, best_x, tier))
step(f" {name} → ({best_x}, {best_y}) terrain={classified[best_y, best_x]}")
return placed
# ─────────────────────────────────────────────────────────────────────────────
# 6. Road network (simple greedy connections)
# ─────────────────────────────────────────────────────────────────────────────
def build_roads(settlements: list) -> list:
"""
Connect tier-1 and tier-2 cities with major roads.
Connect tier-3 nodes to nearest tier-1 or tier-2.
Returns list of (y1, x1, y2, x2, road_type) tuples.
"""
roads = []
tier12 = [(n, y, x) for (n, y, x, t) in settlements if t <= 2]
tier3 = [(n, y, x) for (n, y, x, t) in settlements if t == 3]
# Connect all tier-1/2 cities in order (simple chain + cross-links)
for i in range(len(tier12) - 1):
_, y1, x1 = tier12[i]
_, y2, x2 = tier12[i + 1]
roads.append((y1, x1, y2, x2, "major"))
# Capital spurs to each tier-2
cap = tier12[0]
for city in tier12[1:]:
roads.append((cap[1], cap[2], city[1], city[2], "major"))
# Tier-3 nodes to nearest tier-1/2
for (n3, y3, x3) in tier3:
best_d = 1e9
best = tier12[0]
for city in tier12:
d = (city[1]-y3)**2 + (city[2]-x3)**2
if d < best_d:
best_d = d
best = city
roads.append((y3, x3, best[1], best[2], "minor"))
return roads
# ─────────────────────────────────────────────────────────────────────────────
# 7. Render
# ─────────────────────────────────────────────────────────────────────────────
def render(
terrain: np.ndarray,
rivers: np.ndarray,
output_path: str,
):
"""
Renders a geographic heightmap: terrain colors, rivers, coastlines,
biome zones, lat/lon grid, title panel.
Human layer (settlements, roads, irrigation) is stored in the JSON sidecar only.
River rendering order matters: dilate at source resolution to preserve flow
topology, then paint AFTER upscaling via NEAREST neighbor to avoid LANCZOS
blurring the river network into invisibility.
"""
step("Rendering geographic heightmap…")
from scipy.ndimage import binary_dilation
from PIL import ImageFont
H, W = terrain.shape
classified = classify(terrain)
# UI scale relative to 1024×512 reference resolution
UI_SCALE = OUTPUT_W / 1024
# Base terrain color layer
rgb = np.zeros((H, W, 3), dtype=np.uint8)
for cls_id, color in enumerate(CLASS_COLORS):
mask = classified == cls_id
rgb[mask] = color
# Slight elevation shading on land (ambient occlusion approximation)
land_mask = terrain >= SEA_LEVEL
elev_norm = np.clip((terrain - SEA_LEVEL) / (1.0 - SEA_LEVEL + 1e-9), 0, 1)
shade = (0.85 + 0.15 * elev_norm)[..., np.newaxis]
rgb = np.where(land_mask[..., np.newaxis], (rgb * shade).astype(np.uint8), rgb)
# Dilate river mask at source resolution (preserves branching topology)
rivers_drawn = binary_dilation(rivers, iterations=2)
step(f"River cells after dilation: {rivers_drawn.sum()}")
# Upscale terrain with LANCZOS (smooth gradients) — WITHOUT rivers painted in yet
img = Image.fromarray(rgb).resize((OUTPUT_W, OUTPUT_H), Image.LANCZOS)
# Paint rivers AFTER upscale using NEAREST neighbor — no blur, sharp edges
rivers_up = Image.fromarray((rivers_drawn.astype(np.uint8) * 255)).resize(
(OUTPUT_W, OUTPUT_H), Image.NEAREST
)
img_arr = np.array(img)
img_arr[np.array(rivers_up) > 0] = PALETTE["river"]
img = Image.fromarray(img_arr)
draw = ImageDraw.Draw(img)
# Lat/lon grid lines (every 30°)
grid_w = max(1, int(UI_SCALE))
for lat_pct in [1/6, 2/6, 3/6, 4/6, 5/6]:
y = int(lat_pct * OUTPUT_H)
draw.line([(0, y), (OUTPUT_W, y)], fill=(80, 90, 100), width=grid_w)
for lon_pct in [1/6, 2/6, 3/6, 4/6, 5/6]:
x = int(lon_pct * OUTPUT_W)
draw.line([(x, 0), (x, OUTPUT_H)], fill=(80, 90, 100), width=grid_w)
# Fonts — load_default(size=N) requires Pillow 10+
try:
font_title = ImageFont.load_default(size=int(14 * UI_SCALE))
font_sub = ImageFont.load_default(size=int(12 * UI_SCALE))
font_small = ImageFont.load_default(size=int(11 * UI_SCALE))
except TypeError:
font_title = font_sub = font_small = ImageFont.load_default()
# Title / metadata panel (insert chrome aesthetic)
panel_h = int(56 * UI_SCALE)
panel = Image.new('RGBA', (OUTPUT_W, panel_h), (15, 18, 25, 200))
img = img.convert('RGBA')
img.paste(panel, (0, 0), panel)
img = img.convert('RGB')
draw = ImageDraw.Draw(img)
px = int(12 * UI_SCALE)
py = int(8 * UI_SCALE)
lh = int(18 * UI_SCALE)
draw.text((px, py), f"KALLAST · GJ 144d · Ran System", fill=PALETTE["title_text"], font=font_title)
draw.text((px, py + lh), f"temperate / ocean / breathable / 0.95g / {PLANET['population']//1_000_000_000:.1f}B pop / Wave {PLANET['settlement_wave']} / {PLANET['settlement_age_years']}y settled", fill=(130, 140, 160), font=font_sub)
draw.text((px, py + lh*2), "HEIGHTMAP SPIKE v0.1 — Settled Reach Phase 1", fill=(80, 90, 110), font=font_small)
# Legend (bottom strip) — natural geographic features only
sw = int(16 * UI_SCALE)
legend_y = OUTPUT_H - int(40 * UI_SCALE)
legend_items = [
("ocean", PALETTE["ocean_deep"]),
("coast", PALETTE["coast"]),
("plains", PALETTE["plains"]),
("grassland", PALETTE["grassland"]),
("mountain", PALETTE["mountain"]),
("river", PALETTE["river"]),
]
lx = px
for label, color in legend_items:
draw.rectangle([(lx, legend_y + int(4*UI_SCALE)), (lx+sw, legend_y + sw + int(4*UI_SCALE))], fill=color)
draw.text((lx + sw + int(4*UI_SCALE), legend_y + int(3*UI_SCALE)), label, fill=(180, 185, 200), font=font_small)
lx += int(110 * UI_SCALE)
img.save(output_path, format="PNG", optimize=False)
step(f"Saved: {output_path}")
# ─────────────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Generate Kallast heightmap spike")
parser.add_argument("--output", default="spikes/heightmap-pipeline/kallast_heightmap.png")
parser.add_argument("--terrain-out", default="spikes/heightmap-pipeline/kallast_terrain.npy")
parser.add_argument("--seed", type=int, default=144042) # GJ144d seed
parser.add_argument("--fast", action="store_true", help="Fewer tectonic steps (quicker, less detail)")
args = parser.parse_args()
print(f"\nKallast Heightmap Spike")
print(f" Planet: {PLANET['name']} ({PLANET['body_id']})")
print(f" Grid: {GRID_W}×{GRID_H}")
print(f" Seed: {args.seed}")
print(f" Mode: {'fast' if args.fast else 'standard'}\n")
t0 = time.time()
terrain = run_tectonics(args.seed, fast=args.fast)
terrain = erode(terrain, passes=4)
# Set dynamic sea level based on target land coverage
global SEA_LEVEL
SEA_LEVEL = compute_sea_level(terrain, PLANET["land_fraction"])
rivers = compute_rivers(terrain, threshold=60) # calibrated for 1024×512 grid; flat world needs lower relative threshold
settlements = place_settlements(terrain, KALLAST_CITIES)
render(terrain, rivers, args.output)
np.save(args.terrain_out, terrain)
step(f"Terrain grid saved: {args.terrain_out}")
# Write settlements JSON
sj_path = args.output.replace(".png", "_settlements.json")
with open(sj_path, "w") as f:
json.dump([
{"name": n, "grid_y": int(gy), "grid_x": int(gx), "tier": t}
for (n, gy, gx, t) in settlements
], f, indent=2)
step(f"Settlement data saved: {sj_path}")
elapsed = time.time() - t0
print(f"\nDone in {elapsed:.1f}s")
print(f"Output: {args.output}")
if __name__ == "__main__":
main()