refactor(tooling): bump planet sim to native 1024×512, drop compute_rivers (#963)

- planet_simulation: GRID 512×256 → 1024×512. The elevation noise is
  resolution-independent (normalized coords + absolute freqs), so the
  finer grid samples the SAME terrain — features keep physical size,
  generation stays deterministic. Pixel-unit constants (gaussian sigma,
  crater radii, peak-filter window, erosion slope) scale by GRID_W/512.
  Validated: non-Sol bodies render same-world-crisper at 1024.
- Remove compute_rivers + _rivers_to_grid + the rivers/river_grid terrain
  keys: rivers are the Rust cascade's job (D8 drainage, D-208), the single
  source of river truth. The old heuristic didn't even reach the sea.
- render_heightmap: stop painting rivers onto the relief (cascade/Atlas
  overlay computed rivers instead).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 01:31:52 +02:00
co-authored by Claude Opus 4.7
parent 506b2c7feb
commit cabdd7c097
3 changed files with 28 additions and 130 deletions
+1 -2
View File
@@ -145,8 +145,7 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
else:
print(f" simulate: {t_sim - t0:.1f}s "
f"sea={terrain['sea_level']:.3f} "
f"land={int((~terrain['surface_water']).sum())} "
f"rivers={len(terrain['rivers'])}")
f"land={int((~terrain['surface_water']).sum())}")
# ── 2. Render heightmap ──────────────────────────────────────────────
t_hmap = t_sim
+25 -85
View File
@@ -14,8 +14,6 @@ Output terrain dict:
"biome": int8 (H, W) biome class index
"surface_water": bool (H, W) ocean/lake mask
"hillshade": float32 (H, W) [0, 1] lighting from slope+aspect
"river_grid": bool (H, W) river cell mask
"rivers": list of [(row,col), ...] polylines in grid coords
"sea_level": float elevation threshold
}
@@ -42,8 +40,15 @@ from biome_config import (
)
log = logging.getLogger(__name__)
GRID_W = 512
GRID_H = 256
# Canonical heightmap grid (D-202 amended, #963): bumped to 1024×512 so the
# stored elevation has real mid-scale detail for the lower cascade layers. The
# elevation noise is resolution-independent (normalized coords + absolute
# frequencies), so a higher grid samples the SAME continuous terrain at finer
# density — features keep their physical size and generation stays deterministic.
# Pixel-unit operations (gaussian sigma, crater radii, filter windows) scale by
# `GRID_W / 512` so smoothing/morphology behave identically at any resolution.
GRID_W = 1024
GRID_H = 512
# ---------------------------------------------------------------------------
@@ -256,15 +261,19 @@ def _tectonic_ridges(u, v, seed, n_plates=8):
def _erode(terrain, passes, seed):
result = terrain.copy()
# Resolution scale: smoothing radii and the per-pixel slope (which halves as
# the grid doubles, since np.gradient is in pixel units) scale with width so
# erosion behaves identically at any GRID size.
scale = result.shape[1] / 512.0
for _ in range(passes):
gy, gx = np.gradient(result)
slope = np.sqrt(gx**2 + gy**2)
smooth = gaussian_filter(result, sigma=1.2)
weight = np.clip(slope * 6.0, 0.0, 1.0)
smooth = gaussian_filter(result, sigma=1.2 * scale)
weight = np.clip(slope * 6.0 * scale, 0.0, 1.0)
result = result * (1.0 - weight * 0.35) + smooth * (weight * 0.35)
gy, gx = np.gradient(result)
slope = np.sqrt(gx**2 + gy**2)
flow = gaussian_filter(slope, sigma=3.0)
flow = gaussian_filter(slope, sigma=3.0 * scale)
flow = (flow - flow.min()) / (flow.max() - flow.min() + 1e-9)
result = result - flow * 0.06
return np.clip(result, 0.0, 1.0)
@@ -317,9 +326,10 @@ def compute_elevation(body_def, u, v, lat_frac):
n_craters = max(5, int(base_count * crater_factor))
cy_c = rng.uniform(0, GRID_H, n_craters).astype(np.float32)
cx_c = rng.uniform(0, GRID_W, n_craters).astype(np.float32)
# Power-law: most craters are small (2-5 cells), a few are large (15-30)
# Power-law: most craters are small, a few large. Radii are in pixels,
# so scale with resolution to keep craters the same physical size.
raw_sizes = rng.power(0.4, n_craters) # skewed toward 0
sizes = (2 + raw_sizes * 28).astype(np.float32)
sizes = ((2 + raw_sizes * 28) * (GRID_W / 512.0)).astype(np.float32)
# Depth scales with crater factor — eroded worlds have shallower craters
depth_scale = 0.5 + 0.5 * crater_factor
depths = ((0.05 + raw_sizes * 0.20) * depth_scale).astype(np.float32)
@@ -353,7 +363,7 @@ def compute_elevation(body_def, u, v, lat_frac):
elev = elev + craters
elev = np.clip(elev, 0.0, None) # floor at 0
elif planet_class == "frozen":
elev = gaussian_filter(elev, sigma=1.5).astype(np.float32)
elev = gaussian_filter(elev, sigma=1.5 * (GRID_W / 512.0)).astype(np.float32)
elif planet_class == "volcanic":
erosion_p = max(1, erosion_p - 1)
@@ -537,7 +547,7 @@ def compute_moisture(body_def, elevation, sea_level, temperature,
}
moisture *= hydro_scale.get(hydro, 1.0)
moisture = gaussian_filter(moisture.astype(np.float32), sigma=2.0)
moisture = gaussian_filter(moisture.astype(np.float32), sigma=2.0 * (GRID_W / 512.0))
# Only normalize if the raw range is substantial — otherwise the
# normalization re-inflates near-zero moisture on dry worlds back to [0,1].
m_min, m_max = moisture.min(), moisture.max()
@@ -572,76 +582,12 @@ def compute_hillshade(elevation,
# ---------------------------------------------------------------------------
# 5. Rivers
# Rivers are NOT computed here (D-208, #963): river networks are derived by the
# Rust cascade's D8 drainage from the heightmap — the single source of river
# truth, with mouths that reach the sea by construction. The old heuristic
# `compute_rivers` was removed to avoid implying the Python sim owns rivers.
# ---------------------------------------------------------------------------
def compute_rivers(body_def, elevation, sea_level, moisture,
max_rivers=12):
atmo = body_def["physical"]["atmosphere"]
planet_class = body_def["planet_class"].replace("_ringed", "")
hydro = body_def.get("environment", {}).get("hydrosphere", "none")
if atmo == "none" or hydro in ("none", "subsurface", "ice"):
return []
river_cap = {"barren": 2, "volcanic": 3, "arid": 3, "frozen": 2}
max_rivers = river_cap.get(planet_class, max_rivers)
H, W = elevation.shape
land_mask = elevation >= sea_level
seed = body_def["seed"]
rng = _rng(seed, 500)
from scipy.ndimage import maximum_filter
local_max = (elevation == maximum_filter(elevation, size=8)) & land_mask
moist_ok = moisture > 0.35
candidates = np.argwhere(local_max & moist_ok)
if len(candidates) == 0:
candidates = np.argwhere(land_mask)
np.random.default_rng(seed).shuffle(candidates)
sources = candidates[:min(max_rivers, len(candidates))]
D8 = [(-1,-1),(-1,0),(-1,1),(0,-1),(0,1),(1,-1),(1,0),(1,1)]
rivers = []
for src in sources:
r, c = int(src[0]), int(src[1])
path = [(r, c)]
visited = {(r, c)}
for _ in range(GRID_W * 2):
if elevation[r, c] < sea_level:
break
best_drop = 0.0; best_nr = -1; best_nc = -1
for dr, dc in D8:
nr = r + dr; nc = (c + dc) % W
if nr < 0 or nr >= H or (nr, nc) in visited:
continue
drop = elevation[r, c] - elevation[nr, nc]
drop += float(rng.uniform(-0.005, 0.005))
if drop > best_drop:
best_drop = drop; best_nr = nr; best_nc = nc
if best_nr < 0:
break
r, c = best_nr, best_nc
visited.add((r, c))
path.append((r, c))
if len(path) > 5:
rivers.append(path)
return rivers
def _rivers_to_grid(rivers, H, W):
grid = np.zeros((H, W), dtype=bool)
for path in rivers:
for r, c in path:
if 0 <= r < H and 0 <= c < W:
grid[r, c] = True
return grid
# ---------------------------------------------------------------------------
# 6. Biome
@@ -882,9 +828,6 @@ def simulate(body_def: dict) -> dict:
hillshade = compute_hillshade(elevation)
rivers = compute_rivers(body_def, elevation, sea_level, moisture)
river_grid = _rivers_to_grid(rivers, GRID_H, GRID_W)
biome = compute_biome(
body_def, elevation, sea_level, surface_water, temperature, moisture)
@@ -899,8 +842,6 @@ def simulate(body_def: dict) -> dict:
"biome": biome,
"surface_water": surface_water,
"hillshade": hillshade,
"river_grid": river_grid,
"rivers": rivers,
"sea_level": sea_level,
"_grid_w": GRID_W,
"_grid_h": GRID_H,
@@ -941,7 +882,6 @@ if __name__ == "__main__":
print(f"Done in {dt:.1f}s")
print(f" sea_level: {terrain['sea_level']:.3f}")
print(f" land cells: {(~terrain['surface_water']).sum()}")
print(f" rivers: {len(terrain['rivers'])} polylines")
ids, counts = np.unique(terrain['biome'], return_counts=True)
print(f" biomes: {list(zip(ids.tolist(), counts.tolist()))}")
+2 -43
View File
@@ -197,45 +197,7 @@ def _render_coastline(terrain: dict,
# ---------------------------------------------------------------------------
# Layer 5: Rivers
# ---------------------------------------------------------------------------
def _render_rivers(terrain: dict,
rgb: np.ndarray) -> np.ndarray:
"""
Draw rivers as anti-aliased polylines.
River list is in simulation grid coords (row, col) at GRID_H×GRID_W.
Scale to output pixels, draw with PIL.
"""
rivers = terrain.get("rivers", [])
if not rivers:
return rgb
GRID_H, GRID_W = terrain["_grid_h"], terrain["_grid_w"]
scale_y = OUT_H / GRID_H
scale_x = OUT_W / GRID_W
# Work on a PIL image for anti-aliased line drawing
img = Image.fromarray((rgb * 255).clip(0, 255).astype(np.uint8), mode="RGB")
draw = ImageDraw.Draw(img)
river_col = RIVER_RGB
for path in rivers:
if len(path) < 2:
continue
# Scale grid coords to output pixels
pts = [(int(c * scale_x), int(r * scale_y)) for r, c in path]
# Line width scales with path length — longer rivers are wider.
# Base width doubled for readability at high output resolutions.
width = max(2, min(6, len(path) // 40))
draw.line(pts, fill=river_col, width=width, joint="curve")
return np.array(img).astype(np.float32) / 255.0
# ---------------------------------------------------------------------------
# Layer 6: Lat/lon grid
# Layer 5: Lat/lon grid
# ---------------------------------------------------------------------------
def _render_grid(rgb: np.ndarray) -> np.ndarray:
@@ -435,10 +397,7 @@ def render_heightmap(body_def: dict,
# 4: coastline
rgb = _render_coastline(terrain, rgb)
# 5: rivers
rgb = _render_rivers(terrain, rgb)
# 6: lat/lon grid
# 5: lat/lon grid
rgb = _render_grid(rgb)
# Convert to PIL for text rendering