diff --git a/tooling/planet-gen/generate.py b/tooling/planet-gen/generate.py index 9605b55ef..e3d850a10 100644 --- a/tooling/planet-gen/generate.py +++ b/tooling/planet-gen/generate.py @@ -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 diff --git a/tooling/planet-gen/planet_simulation.py b/tooling/planet-gen/planet_simulation.py index 0862c23ef..85dba1e27 100644 --- a/tooling/planet-gen/planet_simulation.py +++ b/tooling/planet-gen/planet_simulation.py @@ -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()))}") diff --git a/tooling/planet-gen/render_heightmap.py b/tooling/planet-gen/render_heightmap.py index 642b67a83..ca2eeb728 100644 --- a/tooling/planet-gen/render_heightmap.py +++ b/tooling/planet-gen/render_heightmap.py @@ -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