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:
@@ -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
|
||||
|
||||
@@ -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()))}")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user