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>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
@@ -0,0 +1,251 @@
|
||||
# Heightmap Pipeline — Spike Documentation
|
||||
|
||||
**Ticket:** #778
|
||||
**Author:** Araminta
|
||||
**Date:** 2026-04-05
|
||||
**Status:** Spike complete — awaiting review before batch (#794, Sprint 33)
|
||||
**Output:** `heightmaps/GJ144d_kallast.png` (4096×2048px)
|
||||
|
||||
---
|
||||
|
||||
## What This Spike Validates
|
||||
|
||||
This spike validates the full annotated heightmap pipeline from wiki data through to a
|
||||
deliverable PNG. Every stage ran successfully on Kallast (GJ144d, Ran system):
|
||||
|
||||
- pyplatec tectonic simulation → elevation grid
|
||||
- Erosion pass → softer ridges, valley hints
|
||||
- Dynamic sea level → correct 40% land coverage from wiki spec
|
||||
- Terrain classification → 13 biome classes
|
||||
- D8 flow accumulation → river network
|
||||
- Settlement placement snapped to appropriate terrain class
|
||||
- Road network connecting all cities
|
||||
- Annotated render with title/legend in Settled Reach visual grammar
|
||||
|
||||
**The pipeline is confirmed viable for batch production (#794).**
|
||||
|
||||
---
|
||||
|
||||
## Planet: Kallast (GJ144d)
|
||||
|
||||
Selected because it showcases all annotation types:
|
||||
|
||||
| Property | Value | Source |
|
||||
|----------|-------|--------|
|
||||
| Planet ID | `GJ144d` | systems.db |
|
||||
| System | Ran (GJ 144) | systems.db |
|
||||
| Biome | temperate | systems.db |
|
||||
| Hydrosphere | ocean | systems.db |
|
||||
| Land coverage | 40% | wiki: "amber continental shelves" |
|
||||
| Population | 2,000,000,000 | systems.db |
|
||||
| Settlement wave | 1 (580y) | systems.db |
|
||||
| Settlement pattern | urban_concentrated | systems.db |
|
||||
| Industrial | Agricultural_Syndic | systems.db |
|
||||
| Terrain character | Extensive temperate plains, amber-toned grassland | wiki narrative |
|
||||
|
||||
Kallast was chosen over higher-population worlds (Haodu, etc.) because the wiki narrative
|
||||
explicitly describes the terrain features we need to annotate: "amber continental shelves
|
||||
broken by irrigation channels wide enough to see from low orbit." That text is a direct
|
||||
visual brief. The pipeline output should feel consistent with it.
|
||||
|
||||
---
|
||||
|
||||
## Pipeline Architecture
|
||||
|
||||
```
|
||||
Input: Planet profile (wiki + systems.db)
|
||||
↓ planet_type, land_fraction, settlement data
|
||||
|
||||
Stage 1: Tectonic simulation (pyplatec)
|
||||
platec.create(seed, W, H, sea_level=land_fraction, …)
|
||||
platec.step() × 200 [200 steps for mature, well-eroded world]
|
||||
platec.get_heightmap() → float list → reshape → normalize [0, 1]
|
||||
Runtime: ~1s at 512×256 (scales linearly with grid × steps)
|
||||
|
||||
Stage 2: Erosion (scipy gaussian_filter)
|
||||
Slope-weighted smoothing: steep cells erode more
|
||||
4 passes on mature world (reduce to 2 for young volcanic)
|
||||
Runtime: <0.5s at 512×256
|
||||
|
||||
Stage 3: Dynamic sea level
|
||||
sea_level = np.percentile(terrain, (1 - land_fraction) * 100)
|
||||
CRITICAL: pyplatec output is heavily right-skewed (most cells at low
|
||||
elevation). A fixed sea_level fraction (e.g. 0.40) does NOT produce
|
||||
40% land — you get ~0.2% land. Always compute from actual distribution.
|
||||
|
||||
Stage 4: Terrain classification (13 classes)
|
||||
Thresholds as fractions of the land elevation range [sea_level, max]
|
||||
so classification scales correctly across different pyplatec outputs.
|
||||
Classes: ocean_deep → ocean_mid → ocean_shallow → coast → lowland →
|
||||
plains → grassland → hills → forest → highland → mountain →
|
||||
peak → snow
|
||||
|
||||
Stage 5: D8 flow accumulation → river network
|
||||
Sort land cells by elevation descending
|
||||
Each cell drains to steepest downslope neighbour (8-directional)
|
||||
Flow threshold: 30 (calibrated for 512×256 grid with 40% land)
|
||||
Note: threshold scales with grid size and terrain relief — calibrate
|
||||
per planet type. Very flat worlds (like Kallast) need lower threshold.
|
||||
|
||||
Stage 6: Settlement placement
|
||||
For each city from wiki data: snap to nearest plains/grain_belt cell
|
||||
within expanding search radius (20 → 40 → 60 → 80 cells)
|
||||
Preference order: plains (class 5) > grain_belt (6) > lowland (4) >
|
||||
coast (3) > hills (7)
|
||||
|
||||
Stage 7: Road network
|
||||
Tier-1 and tier-2 cities connected by major roads (all-pairs from capital)
|
||||
Tier-3 nodes connected to nearest tier-1/2 by minor roads
|
||||
Rendered as polylines on the annotated layer
|
||||
|
||||
Stage 8: Geographic render (PIL)
|
||||
1. Base terrain color layer (RGB from class colors)
|
||||
2. Elevation shading on land (ambient occlusion proxy)
|
||||
3. Dilate river mask at source grid resolution (2 iterations, preserves topology)
|
||||
4. Scale up terrain to output resolution (4096×2048) via LANCZOS
|
||||
5. Paint rivers AFTER upscale via NEAREST-neighbor upscaled mask
|
||||
CRITICAL: painting before LANCZOS blurs rivers into invisibility.
|
||||
Post-upscale NEAREST gives each source cell a 4×4px block — clearly legible.
|
||||
6. Lat/lon grid lines (every 30°), scaled width
|
||||
7. Title panel + legend — natural geographic features only
|
||||
(ocean, coast, plains, grassland, mountain, river)
|
||||
Text/panel sizes scale with UI_SCALE = OUTPUT_W / 1024
|
||||
NOTE: settlements, roads, freight elevators are NOT rendered here.
|
||||
They live in the JSON sidecar and are overlaid by the atlas app.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration per Planet Type
|
||||
|
||||
For the batch run (#794), per-planet config differs in:
|
||||
|
||||
| Parameter | Kallast | Young volcanic | Ice world | Desert | Ocean world |
|
||||
|-----------|---------|----------------|-----------|--------|-------------|
|
||||
| `plate_count` | 10 | 4 | 7 | 6 | 8 |
|
||||
| `sim_steps` | 200 | 100 | 150 | 150 | 180 |
|
||||
| `erosion_passes` | 4 | 1 | 3 (glacial) | 2 (aeolian) | 3 |
|
||||
| `land_fraction` | 0.40 | 0.55 | 0.30 | 0.60 | 0.15 |
|
||||
| `river_threshold` | 60 | 320 | 80 | 60 | 200 |
|
||||
|
||||
The `land_fraction` comes directly from the wiki's hydrosphere field:
|
||||
- `ocean` → 0.30–0.45
|
||||
- `liquid_water` → 0.40–0.60
|
||||
- `ice` → 0.20–0.35
|
||||
- `none` → 0.90–0.99
|
||||
|
||||
---
|
||||
|
||||
## Two-Layer Model
|
||||
|
||||
Heightmaps are **geographic only**. Human data lives in JSON sidecars.
|
||||
|
||||
```
|
||||
kallast_heightmap.png ← geographic render: terrain, rivers, biomes, grid
|
||||
kallast_heightmap_settlements.json ← human layer: city names + grid coordinates
|
||||
```
|
||||
|
||||
The PNG renders: terrain classification colors, elevation shading, dilated river
|
||||
network, lat/lon grid, title panel.
|
||||
|
||||
The PNG does NOT render: settlements, roads, freight elevators, city labels, irrigation
|
||||
channels, or any human-activity markers. Those exist in the JSON sidecar and are overlaid
|
||||
separately by the atlas app (Phase 3) when the map is interactive.
|
||||
|
||||
**Rationale:** A geographic heightmap is a stable base layer. The human overlay changes
|
||||
as the simulation runs (cities grow, shrink, change character). Keeping them separate
|
||||
means the PNG can be regenerated from terrain data without recomputing settlement
|
||||
placement, and vice versa.
|
||||
|
||||
## Output Files
|
||||
|
||||
| File | Size | Description |
|
||||
|------|------|-------------|
|
||||
| `kallast_heightmap.png` | 4096×2048px | Geographic world map (deliverable) |
|
||||
| `kallast_terrain.npy` | ~2MB | Raw normalised elevation grid (numpy float32, 1024×512) |
|
||||
| `kallast_heightmap_settlements.json` | <1KB | City positions for atlas DB import (human layer sidecar) |
|
||||
|
||||
For batch production, the `.npy` and `.json` files are inputs to the Phase 3
|
||||
atlas pipeline — they pre-seed the city layer rather than requiring re-computation.
|
||||
|
||||
---
|
||||
|
||||
## Known Issues / Calibration Notes for Batch
|
||||
|
||||
1. **River painting order is critical.** Painting river pixels into the source-resolution
|
||||
array before LANCZOS upscaling blurs them into invisibility. Always dilate at source
|
||||
resolution, then upscale with NEAREST neighbor and paint AFTER. Enforced in `render()`.
|
||||
|
||||
2. **Flat worlds produce sparse rivers.** Kallast has low terrain relief. Threshold=60
|
||||
at 1024×512 gives 113 pre-dilation cells (1164 post). Scale threshold with grid area:
|
||||
`threshold_1024 ≈ threshold_512 * 4`. For this flat world, halve the baseline to get
|
||||
denser coverage.
|
||||
|
||||
3. **City placement uses wiki narrative coordinates, not astrophysical simulation.**
|
||||
Relative positions (e.g. "Kallast Prime at 45% longitude, 48% latitude") are editorial
|
||||
decisions. The snap algorithm finds nearest suitable terrain class within search radius.
|
||||
This is intentional — settlement locations should reflect the world's narrative.
|
||||
|
||||
4. **Agricultural layer.** The wiki describes irrigation channels wide enough to see
|
||||
from orbit. These are human infrastructure — they belong in the JSON sidecar, not the
|
||||
geographic heightmap. Phase 3 atlas work should render irrigation channels as a
|
||||
separate overlay from hydrology + settlement data.
|
||||
|
||||
---
|
||||
|
||||
## Batch Run Estimate (#794)
|
||||
|
||||
Grid size: 1024×512. Output: 4096×2048.
|
||||
|
||||
| Phase | Step | Time per planet | 301 planets |
|
||||
|-------|------|-----------------|-------------|
|
||||
| Tectonic (200 steps, 1024×512) | ~3.5s | 1054s |
|
||||
| Erosion (4 passes) | ~1.0s | 301s |
|
||||
| Hydrology | ~0.5s | 151s |
|
||||
| Placement + roads | ~0.5s | 151s |
|
||||
| Render + export | ~0.8s | 241s |
|
||||
| **Total** | | **~6.3s/planet** | **~32 minutes** |
|
||||
|
||||
Full batch of 301 systems runs in ~32 minutes single-threaded. Parallelisable across all
|
||||
CPU cores (no shared state) — realistically ~8 minutes on 4 cores.
|
||||
|
||||
Note: if batch time is a concern, `sim_steps=100` halves tectonic time with acceptable
|
||||
terrain quality for most planet types. Only mature worlds (Kallast, old ocean worlds)
|
||||
benefit meaningfully from 200 steps.
|
||||
|
||||
---
|
||||
|
||||
## Running the Spike
|
||||
|
||||
```bash
|
||||
# Standard (200 tectonic steps, ~1.5s)
|
||||
python3 spikes/heightmap-pipeline/generate_kallast.py
|
||||
|
||||
# Fast mode (50 steps — good for testing annotation, poor terrain)
|
||||
python3 spikes/heightmap-pipeline/generate_kallast.py --fast
|
||||
|
||||
# Different seed (changes continent layout)
|
||||
python3 spikes/heightmap-pipeline/generate_kallast.py --seed 42
|
||||
|
||||
# Custom output path
|
||||
python3 spikes/heightmap-pipeline/generate_kallast.py --output heightmaps/GJ144d_kallast_v2.png
|
||||
```
|
||||
|
||||
Dependencies: `pyplatec`, `scipy`, `numpy`, `Pillow` (all installable via pip)
|
||||
|
||||
---
|
||||
|
||||
## Open Questions for Review
|
||||
|
||||
Before starting batch (#794), Jeroen should confirm:
|
||||
|
||||
1. **Visual style.** Does the terrain color palette work? The grassland amber
|
||||
(`#a59b4b`) reads as temperate plains — is this the right mood for Kallast?
|
||||
2. **Annotation density.** 12 settlements for a 2B-population world — too sparse? too
|
||||
many? For the batch, settlement count would be derived from wiki city data (if any)
|
||||
or a formula from population + settlement_pattern.
|
||||
3. **Output resolution.** 1024×512 adequate for wiki use? Or do we need 2048×1024
|
||||
for the implant atlas app (Phase 3)?
|
||||
4. **River threshold calibration.** The flat terrain of Kallast needed threshold=30.
|
||||
Should we auto-calibrate per planet by targeting N river-mouth cells, rather than
|
||||
a fixed threshold?
|
||||
@@ -0,0 +1,568 @@
|
||||
#!/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()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
@@ -0,0 +1,74 @@
|
||||
[
|
||||
{
|
||||
"name": "Kallast Prime",
|
||||
"grid_y": 297,
|
||||
"grid_x": 460,
|
||||
"tier": 1
|
||||
},
|
||||
{
|
||||
"name": "Ardenvall",
|
||||
"grid_y": 182,
|
||||
"grid_x": 246,
|
||||
"tier": 2
|
||||
},
|
||||
{
|
||||
"name": "Thessmark",
|
||||
"grid_y": 266,
|
||||
"grid_x": 613,
|
||||
"tier": 2
|
||||
},
|
||||
{
|
||||
"name": "Coldwater",
|
||||
"grid_y": 316,
|
||||
"grid_x": 272,
|
||||
"tier": 2
|
||||
},
|
||||
{
|
||||
"name": "Brightfield",
|
||||
"grid_y": 172,
|
||||
"grid_x": 512,
|
||||
"tier": 2
|
||||
},
|
||||
{
|
||||
"name": "Vorn's Crossing",
|
||||
"grid_y": 293,
|
||||
"grid_x": 399,
|
||||
"tier": 3
|
||||
},
|
||||
{
|
||||
"name": "Saltmere",
|
||||
"grid_y": 195,
|
||||
"grid_x": 707,
|
||||
"tier": 3
|
||||
},
|
||||
{
|
||||
"name": "Kaspel",
|
||||
"grid_y": 214,
|
||||
"grid_x": 214,
|
||||
"tier": 3
|
||||
},
|
||||
{
|
||||
"name": "Drenmark",
|
||||
"grid_y": 296,
|
||||
"grid_x": 737,
|
||||
"tier": 3
|
||||
},
|
||||
{
|
||||
"name": "New Farrow",
|
||||
"grid_y": 327,
|
||||
"grid_x": 563,
|
||||
"tier": 3
|
||||
},
|
||||
{
|
||||
"name": "Ossenfield",
|
||||
"grid_y": 155,
|
||||
"grid_x": 433,
|
||||
"tier": 3
|
||||
},
|
||||
{
|
||||
"name": "Tyne Station",
|
||||
"grid_y": 300,
|
||||
"grid_x": 322,
|
||||
"tier": 3
|
||||
}
|
||||
]
|
||||
Binary file not shown.
Reference in New Issue
Block a user