"""Sea-floor detail for refined areas: turbidity canyons and their fans, sediment ponds, fine volcanic fields on the sunken plateaus, vents (data) and the refined sea-floor fields. Only sea cells inside the area change, and never above SEA_TOP_M; land and the halo keep their heights. Deterministic: the same cells and world values give the same floor. Generated terrain (`idea`).""" from __future__ import annotations import json from pathlib import Path import numpy as np from scipy.spatial import cKDTree import h3par import worldgen_path # noqa: F401 (mapgen on sys.path) from mapgen import plateaus as PL, seabed as SB from mapgen.graph import accumulate, components, priority_flood, receiver_levels, smooth_km, steepest_receivers from mapgen.noise import name_seed from mapgen.sphere import east_north, gc_dist_km, great_circle_point, latlon_to_xyz from mapgen.zones import smootherstep SEA_TOP_M = -5.0 # the pass never lifts a sea cell above this (no new land) WALL_M = 1.0e7 # land in the sea-floor routing CANYON = {"k": 0.1, "m": 0.5, "n": 1.0, "min_area_km2": 500.0, "min_slope": 2.0, "cap_m": 1500.0} # slope: m/km FAN = {"flat_slope": 1.0, "max_m": 100.0, "km": 15.0} POND = {"min_depth_m": 50.0, "fill": 0.3} VENT_TYPES = ["black smoker", "white smoker", "diffuse", "cold seep"] VENT_MINERALS = ["copper-iron sulfides", "zinc sulfides and barite", "iron-manganese oxides", "methane carbonates"] VENT = {"rate": 0.03, "seep_rate": 0.002, "min_depth_m": 200.0, "ridge_km": 60.0, "reach_km": 3.0} TEMP_C = (np.array([300.0, 100.0, 10.0, 0.0]), np.array([100.0, 200.0, 90.0, 5.0])) # per type: from, span def canyons(g, z, sea, halo, P=CANYON, F=FAN): """(heights, canyon depth, fan thickness). Turbidity flows follow the steepest descent over the sea floor (land is a wall); where their contributing sea area and slope are high they cut (stream-power form, like rivers, capped at cap_m); the material they carry settles as a fan where they first reach flat floor (slope < flat_slope), spread over ≈ km and at most max_m thick. Canyons only lower, fans only raise; the halo stays.""" z = np.asarray(z, dtype=np.float64) sea, halo = np.asarray(sea, bool), np.asarray(halo, bool) active = sea & ~halo recv, slope, _ = steepest_receivers(g, np.where(sea, z, WALL_M)) levels = receiver_levels(recv) area = accumulate(recv, levels, np.where(sea, g.area_km2, 0.0)) cut = np.where(active & (area >= P["min_area_km2"]) & (slope >= P["min_slope"]), np.minimum(P["cap_m"], P["k"] * area ** P["m"] * slope ** P["n"]), 0.0) vol = accumulate(recv, levels, cut * g.area_km2 / 1000.0) # km³ carried down flat = active & (slope < F["flat_slope"]) ar = np.arange(g.n) src = active & ~flat & (recv != ar) & (vol > 0) entry = np.zeros(g.n, bool) entry[recv[src]] = True entry &= flat dep = np.where(entry, vol * 1000.0 / g.area_km2, 0.0) # m over the entry cell fan = np.where(active & (cut == 0), np.clip(smooth_km(g, dep, F["km"]), 0.0, F["max_m"]), 0.0) zc = z - cut zn = np.where(fan > 0, np.maximum(zc, np.minimum(zc + fan, SEA_TOP_M)), zc) return zn, cut, zn - zc def ponds(g, z, sea, halo, sediment_m, P=POND): """(heights, fill thickness): closed sea-floor hollows deeper than min_depth_m below their spill level fill with sediment to a flat floor at min(spill level, lowest point + fill × the mean sediment there). Never above the spill level, never lower.""" z = np.asarray(z, dtype=np.float64) sea, halo = np.asarray(sea, bool), np.asarray(halo, bool) out = z.copy() if not (sea & ~halo).any(): return out, np.zeros(g.n) sinks = sea & halo if not sinks.any(): # sea closed inside the area: its deepest cell drains sinks = np.zeros(g.n, bool) s = np.flatnonzero(sea) sinks[s[np.argmin(z[s])]] = True zf = priority_flood(g, np.where(sea, z, WALL_M), sinks, eps=0.0) hollow = sea & ~halo & (zf - z > 0.5) if not hollow.any(): return out, np.zeros(g.n) lab = components(g, hollow) idx = np.flatnonzero(hollow) L = lab[idx] n = int(L.max()) + 1 low = np.full(n, np.inf) np.minimum.at(low, L, z[idx]) spill = np.full(n, -np.inf) np.maximum.at(spill, L, zf[idx]) cnt = np.bincount(L, minlength=n) sup = np.bincount(L, weights=np.asarray(sediment_m, dtype=np.float64)[idx], minlength=n) / np.maximum(cnt, 1) level = np.minimum(spill, low + P["fill"] * sup) deep = (spill - low) >= P["min_depth_m"] lv = np.where(deep[L], np.minimum(level[L], SEA_TOP_M), -np.inf) out[idx] = np.maximum(z[idx], lv) return out, out - z def _u(ids, salt: int) -> np.ndarray: """Uniform [0, 1) per H3 cell id (splitmix64): a cell draws the same number in every area and every era.""" x = np.asarray(ids, dtype=np.uint64) + np.uint64((int(salt) * 0x9E3779B97F4A7C15) & 0xFFFFFFFFFFFFFFFF) x = x + np.uint64(0x9E3779B97F4A7C15) x = (x ^ (x >> np.uint64(30))) * np.uint64(0xBF58476D1CE4E5B9) x = (x ^ (x >> np.uint64(27))) * np.uint64(0x94D049BB133111EB) x = x ^ (x >> np.uint64(31)) return (x >> np.uint64(11)).astype(np.float64) / float(1 << 53) def _around(rng, lat, lon, reach_km, radius_km): c = latlon_to_xyz(lat, lon) e, n = east_north(c[None]) az = rng.uniform(0.0, 2.0 * np.pi) return great_circle_point(c, np.cos(az) * n[0] + np.sin(az) * e[0], rng.uniform(0.0, reach_km), radius_km) def small_features(p: dict, feat: dict, seed: int, radius_km: float) -> list: """A plateau's fine volcanic features, deterministic per name and independent of any area: 2–6 cones (2–15 km wide, 0.2–2 km tall) around each world-scale cone, 1–2 pit calderas in each world caldera, and a fissure ridge from the hotspot to each caldera (the hotspot's feeding track).""" rng = np.random.default_rng(name_seed(seed, p["name"]) + 11) vent = float(p.get("vent", 1.0)) out = [] for c in feat["cones"]: for _ in range(int(rng.integers(2, 7))): out.append({"kind": "cone", "at": _around(rng, c["lat"], c["lon"], c["radius_km"], radius_km), "radius_km": float(rng.uniform(1.0, 7.5)), "height_m": float(rng.uniform(200.0, 2000.0)) * vent}) for c in feat["calderas"]: for _ in range(int(rng.integers(1, 3))): out.append({"kind": "pit", "at": _around(rng, c["lat"], c["lon"], 0.5 * c["radius_km"], radius_km), "radius_km": float(rng.uniform(2.0, 6.0)), "height_m": 150.0 * vent, "floor_m": -250.0 * vent}) hot = latlon_to_xyz(*feat["hotspot"]) for c in feat["calderas"]: out.append({"kind": "fissure", "at": hot, "to": latlon_to_xyz(c["lat"], c["lon"]), "radius_km": 1.5, "height_m": float(rng.uniform(100.0, 400.0)) * vent}) return out def _segment_km(xyz, a, b, radius_km, step_km=1.0): n = max(2, int(gc_dist_km(a, b, radius_km) / step_km) + 1) t = np.linspace(0.0, 1.0, n)[:, None] pts = a * (1.0 - t) + b * t pts /= np.linalg.norm(pts, axis=1, keepdims=True) chord, _ = cKDTree(pts).query(xyz, workers=max(1, h3par.workers())) return 2.0 * np.arcsin(np.clip(chord / 2.0, 0.0, 1.0)) * radius_km def volcanic(g, sea, halo, plateau_id, plateaus: list, seed: int): """(height to add, nearness 0–1 to a feature) from the fine volcanic features of the plateaus under the area's sea cells (vent = 0: none).""" add, near = np.zeros(g.n), np.zeros(g.n) for k, p in enumerate(plateaus): on = np.flatnonzero((np.asarray(plateau_id) == k) & sea & ~halo) if not len(on) or float(p.get("vent", 1.0)) <= 0: continue xyz = g.xyz[on] for f in small_features(p, PL.features(p, seed, g.radius_km), seed, g.radius_km): r = f["radius_km"] if f["kind"] == "fissure": d = _segment_km(xyz, f["at"], f["to"], g.radius_km) h = f["height_m"] * np.exp(-(d / r) ** 2) else: d = gc_dist_km(xyz, f["at"], g.radius_km) if f["kind"] == "cone": h = f["height_m"] * np.clip(1.0 - d / r, 0.0, 1.0) ** 1.5 else: h = f["height_m"] * np.exp(-((d - r) / (0.25 * r)) ** 2) + f["floor_m"] * (1.0 - smootherstep(d / r)) add[on] += h near[on] = np.maximum(near[on], np.exp(-(d / (r + 3.0)) ** 2)) return add, near def clamp_plateaus(z, sea, halo, plateau_id, plateaus: list): """Hidden plateaus keep every sea cell at least HIDDEN_MAX_M (−550 m) deep.""" z = np.asarray(z, dtype=np.float64).copy() for k, p in enumerate(plateaus): if not p.get("islands", False): m = (np.asarray(plateau_id) == k) & sea & ~halo z[m] = np.minimum(z[m], PL.HIDDEN_MAX_M) return z def vents(g, z, sea, halo, potential, near, d_div_km, sediment_m, bottom_c, seed: int, V=VENT): """(per-cell vent strength 0–1, vents). A sea cell deeper than min_depth_m holds a vent with probability rate × potential × (0.5 + nearness to a volcanic feature); on a ridge axis (d_div_km < ridge_km) vents are black smokers, near a feature black (60 %) or white smokers, elsewhere diffuse; cold seeps sit in thick sediment where the potential is low. Temperature and flow class (0–2) per vent; the strength spreads ≈ reach_km around each.""" depth = -np.asarray(z, dtype=np.float64) cand = sea & ~halo & (depth > V["min_depth_m"]) pot = np.clip(np.asarray(potential, dtype=np.float64), 0.0, 1.0) near = np.asarray(near, dtype=np.float64) u = [_u(g.ids, seed * 8 + s) for s in range(5)] hot = cand & (u[0] < V["rate"] * pot * (0.5 + near)) seep = cand & ~hot & (pot < 0.2) & (np.asarray(sediment_m) > 1000.0) & (u[1] < V["seep_rate"]) i = np.flatnonzero(hot | seep) ridge = np.asarray(d_div_km)[i] < V["ridge_km"] close = near[i] > 0.3 t = np.where(seep[i], 3, np.where(ridge | (close & (u[2][i] < 0.6)), 0, np.where(close, 1, 2))).astype(np.int8) lo, span = TEMP_C temp = np.where(t == 3, np.asarray(bottom_c, dtype=np.float64)[i], 0.0) + lo[t] + span[t] * u[3][i] flow = np.minimum((3.0 * u[4][i] * np.sqrt(pot[i])).astype(np.int8), 2).astype(np.int8) strength = np.zeros(g.n) if len(i): reach = V["reach_km"] / g.radius_km d, k = cKDTree(g.xyz[i]).query(g.xyz, distance_upper_bound=3.0 * reach, workers=max(1, h3par.workers())) ok = np.isfinite(d) & sea w = 0.5 + 0.25 * flow strength[ok] = np.clip(w[k[ok]] * np.exp(-(d[ok] / reach) ** 2), 0.0, 1.0) out = {"cell": g.ids[i].astype(np.uint64), "lat": g.lat[i].astype(np.float64), "lon": g.lon[i].astype(np.float64), "type": t, "temp_c": temp.astype(np.float32), "flow": flow, "mineral": t.copy()} return strength, out def fields(pv: dict, sea, halo, cut, fan, fill, volc, strength) -> dict: """The refined sea-floor fields (0 / none on land): the world cell's values; sea over a world land cell (a refined coast) starts as terrigenous sediment; canyons scour (their fans and pond fill add sediment) and read as terrigenous; fine volcanic features read as volcanic floor; vent fields over the vents (sulfides, no sediment, warmer water).""" sea = np.asarray(sea, bool) t = np.asarray(pv["seabed_type"]).astype(np.int8).copy() m = np.asarray(pv["seabed_mineral"]).astype(np.int8).copy() sed = np.asarray(pv["sediment_m"], dtype=np.float64).copy() bt = np.asarray(pv["bottom_temp_c"], dtype=np.float64).copy() orphan = sea & (t == SB.SB_NONE) t[orphan] = SB.SB_TERRIGENOUS sed[orphan] = 300.0 bt[orphan] = np.maximum(np.asarray(pv["T_mean"], dtype=np.float64)[orphan], -1.8) cut, fan, fill = (np.asarray(a, dtype=np.float64) for a in (cut, fan, fill)) volc, strength = np.asarray(volc, dtype=np.float64), np.asarray(strength, dtype=np.float64) sed = np.where(cut > 0, 0.3 * sed, sed) + fan + fill t[(cut > 50.0) | (fan > 1.0)] = SB.SB_TERRIGENOUS t[np.abs(volc) > 50.0] = SB.SB_VOLCANIC vf = strength > 0.5 t[vf] = SB.SB_VENTS m[vf] = SB.MI_SULFIDES sed[vf] = 0.0 bt = bt + 20.0 * strength land = ~sea t[land], m[land] = SB.SB_NONE, SB.MI_NONE z32 = lambda a: np.where(land, 0.0, a).astype(np.float32) return {"seabed_type": t, "seabed_mineral": m, "bottom_temp_c": z32(bt), "sediment_m": z32(sed), "vent": z32(strength), "canyon_m": z32(cut), "fan_m": z32(fan)} PV_KEYS = ("vent_potential", "seabed_type", "seabed_mineral", "bottom_temp_c", "sediment_m", "d_div_km", "T_mean") def run(g, z, sea, halo, parent, world_a, plateaus: list, seed: int): """The whole pass: (heights, refined sea-floor fields incl. plateau_id, vents).""" sea, halo = np.asarray(sea, bool), np.asarray(halo, bool) pv = {k: np.asarray(world_a[k])[parent] for k in PV_KEYS} plateau_id = (PL.cell_ids(g.xyz, plateaus, seed, g.radius_km) if plateaus else np.full(g.n, -1, np.int16)) z0 = np.asarray(z, dtype=np.float64) zc, cut, fan = canyons(g, z0, sea, halo) zp, fill = ponds(g, zc, sea, halo, pv["sediment_m"]) add, near = volcanic(g, sea, halo, plateau_id, plateaus, seed) zv = np.where(sea & ~halo, np.minimum(zp + add, np.maximum(zp, SEA_TOP_M)), zp) zn = clamp_plateaus(zv, sea, halo, plateau_id, plateaus) strength, vt = vents(g, zn, sea, halo, pv["vent_potential"], near, pv["d_div_km"], pv["sediment_m"], pv["bottom_temp_c"], seed) f = fields(pv, sea, halo, cut, fan, fill, zv - zp, strength) f["plateau_id"] = plateau_id.astype(np.int16) return zn, f, vt VENT_RGB = [(230, 40, 30), (245, 245, 245), (250, 160, 40), (40, 220, 230)] # by VENT_TYPES def preview(area_dir: Path, path: Path, px: int = 1400) -> Path: """A shaded depth image of a refined area (its box, north up) with its vents as dots — black smokers red, white smokers white, diffuse orange, cold seeps cyan: a first look at a sea floor before the viewer shows it.""" from PIL import Image, ImageDraw area_dir, path = Path(area_dir), Path(path) with np.load(area_dir / "cells.npz") as a: inner = ~a["halo"].astype(bool) lat, lon = a["g_lat"][inner], a["g_lon"][inner] z, sea = a["elevation_eroded_m"][inner].astype(np.float64), a["ocean"][inner].astype(bool) c0 = float(np.degrees(np.arctan2(np.sin(np.radians(lon)).mean(), np.cos(np.radians(lon)).mean()))) x = (lon - c0 + 180.0) % 360.0 - 180.0 k = float(np.cos(np.radians(lat.mean()))) W = int(px) H = max(2, int(round(W * (lat.max() - lat.min()) / max((x.max() - x.min()) * k, 1e-9)))) GX, GY = np.meshgrid(np.linspace(x.min(), x.max(), W), np.linspace(lat.max(), lat.min(), H)) pts = np.column_stack([x * k, lat]) tree = cKDTree(pts) dist, j = tree.query(np.column_stack([GX.ravel() * k, GY.ravel()])) spacing = float(np.median(tree.query(pts, k=2)[0][:, 1])) Z, S = z[j].reshape(H, W), sea[j].reshape(H, W) px_km = (lat.max() - lat.min()) * 111.2 / H gy, gx = np.gradient(Z, px_km) shade = np.clip(1.0 - 0.015 * (gx - gy), 0.45, 1.35)[..., None] t = np.clip(-Z / 6000.0, 0.0, 1.0)[..., None] rgb = np.where(S[..., None], (1.0 - t) * np.array([150.0, 210.0, 235.0]) + t * np.array([10.0, 30.0, 80.0]), np.array([125.0, 140.0, 95.0])) * shade rgb[(dist > 2.0 * spacing).reshape(H, W)] = 40.0 im = Image.fromarray(np.clip(rgb, 0, 255).astype(np.uint8)) if (area_dir / "vents.npz").exists(): d = ImageDraw.Draw(im) with np.load(area_dir / "vents.npz") as v: vx = ((v["lon"] - c0 + 180.0) % 360.0 - 180.0 - x.min()) / max(x.max() - x.min(), 1e-9) * (W - 1) vy = (lat.max() - v["lat"]) / max(lat.max() - lat.min(), 1e-9) * (H - 1) for a_, b_, ty in zip(vx.tolist(), vy.tolist(), v["type"].tolist()): d.ellipse([a_ - 3, b_ - 3, a_ + 3, b_ + 3], fill=VENT_RGB[int(ty)], outline=(0, 0, 0)) path.parent.mkdir(parents=True, exist_ok=True) im.save(path, quality=90) return path def preview_region(root: Path, res: int, region_id: str, regions_path: Path | None = None, regions_root: Path | None = None, out_dir: Path | None = None) -> Path: """preview() of a region's refined area in every world that has it: /-.jpg.""" import refine as RF root = Path(root) regions = RF.load_regions(regions_path or root / "places" / "regions.json") if not any(RF.region_key(r) == region_id for r in regions): raise SystemExit(f"no region {region_id} in {regions_path or 'places/regions.json'}") fine = res + RF.FINER h = RF.region_areas(regions, RF.area_cells(regions, fine), fine)[region_id]["area"] rroot = Path(regions_root or root / "out" / f"r{res}" / "regions") out_dir = Path(out_dir or root / "previews" / f"r{res}" / "seafloor") made = [] for s in sorted(p for p in rroot.glob("*-m*") if p.is_dir()): if h and (s / h / "cells.npz").exists(): era = json.loads((s / "era.json").read_text())["era"] if (s / "era.json").exists() else s.name made.append(preview(s / h, out_dir / f"{region_id}-{era}.jpg")) if not made: raise SystemExit(f"{region_id} is not refined at res {res}: run mapgen.py refine --res {res}") return out_dir