1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
|
"""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: <out_dir>/<region>-<era>.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
|