worldgen

git clone https://git.godosa.eu/worldgen

master

raw · 7886 bytes

"""GeoJSON exports: rivers, coastlines/lakes (marching squares), plate boundaries."""
from __future__ import annotations

import json
from pathlib import Path

import h3.api.basic_int as h3
import numpy as np

BOUNDARY_NAMES = {1: "convergent", 2: "divergent", 3: "transform"}
# marching-squares cases; corner bits TL=8 TR=4 BR=2 BL=1; edges T R B L
_CASES = {1: [("L", "B")], 2: [("B", "R")], 3: [("L", "R")], 4: [("T", "R")], 5: [("T", "R"), ("L", "B")],
          6: [("T", "B")], 7: [("L", "T")], 8: [("L", "T")], 9: [("T", "B")], 10: [("L", "T"), ("B", "R")],
          11: [("T", "R")], 12: [("L", "R")], 13: [("B", "R")], 14: [("L", "B")]}
_EDGE = {"T": (1, 0), "R": (2, 1), "B": (1, 2), "L": (0, 1)}   # doubled (dx, dy) inside a 2x2 block


def split_antimeridian(coords):
    parts, cur = [], [list(coords[0])]
    for a, b in zip(coords, coords[1:]):
        if abs(b[0] - a[0]) > 180.0:
            parts.append(cur)
            cur = []
        cur.append(list(b))
    parts.append(cur)
    return [p for p in parts if len(p) >= 2]


def _feature(geom_type, coords, props):
    return {"type": "Feature", "geometry": {"type": geom_type, "coordinates": coords}, "properties": props}


def river_lines(g, recv, river, strahler, discharge):
    """One LineString per run of equal Strahler order (runs share their junction vertex)."""
    n = g.n
    ar = np.arange(n)
    has_donor = np.zeros(n, bool)
    nr = river & (recv != ar)
    has_donor[recv[nr]] = True
    visited = np.zeros(n, bool)
    feats = []
    for s in np.flatnonzero(river & ~has_donor):
        cells, c = [int(s)], int(s)
        while True:
            visited[c] = True
            r = int(recv[c])
            if r == c:
                break
            cells.append(r)
            if not river[r] or visited[r]:
                break
            c = r
        body = cells[:-1] if len(cells) > 1 else cells
        start = 0
        for i in range(1, len(body) + 1):
            if i == len(body) or strahler[body[i]] != strahler[body[i - 1]]:
                run = body[start:i] + [cells[i] if i < len(cells) else body[-1]]
                coords = [[round(float(g.lon[k]), 4), round(float(g.lat[k]), 4)] for k in run]
                props = {"order": int(strahler[body[start]]), "discharge_km3_yr": round(float(discharge[body[i - 1]]), 3)}
                feats += [_feature("LineString", p, props) for p in split_antimeridian(coords)]
                start = i
    return feats


def boundary_features(g, plate, bnd_type):
    s, d = g.src, g.dst
    e = np.flatnonzero((plate[s] != plate[d]) & (s < d))
    lines = {}
    for k in e:
        a, b = int(g.ids[s[k]]), int(g.ids[d[k]])
        seg = [[round(lng, 4), round(lat, 4)] for lat, lng in h3.directed_edge_to_boundary(h3.cells_to_directed_edge(a, b))]
        if abs(seg[0][0] - seg[-1][0]) > 180:
            continue
        lines.setdefault(BOUNDARY_NAMES.get(int(bnd_type[s[k]]), "transform"), []).append(seg)
    return [_feature("MultiLineString", v, {"type": t}) for t, v in sorted(lines.items())]


def contours(mask):
    m = np.pad(np.asarray(mask, dtype=np.uint8), 1)
    code = m[:-1, :-1] * 8 + m[:-1, 1:] * 4 + m[1:, 1:] * 2 + m[1:, :-1]
    adj = {}
    for case, segs in _CASES.items():
        ys, xs = np.nonzero(code == case)
        for (e1, e2) in segs:
            for y, x in zip(ys.tolist(), xs.tolist()):
                p = (2 * x + _EDGE[e1][0], 2 * y + _EDGE[e1][1])
                q = (2 * x + _EDGE[e2][0], 2 * y + _EDGE[e2][1])
                adj.setdefault(p, []).append(q)
                adj.setdefault(q, []).append(p)
    seen, rings = set(), []
    for start in adj:
        if start in seen:
            continue
        ring, prev, cur = [start], None, start
        seen.add(start)
        while True:
            nxt = [q for q in adj[cur] if q != prev]
            if not nxt:
                break
            prev, cur = cur, nxt[0]
            ring.append(cur)
            if cur == start:
                break
            seen.add(cur)
        rings.append([(px / 2.0 - 1.0, py / 2.0 - 1.0) for px, py in ring])
    return rings


def _signed_area(ring):
    a = np.asarray(ring, dtype=np.float64)
    return 0.5 * float(np.sum(a[:-1, 0] * a[1:, 1] - a[1:, 0] * a[:-1, 1]))


def _point_in_ring(pt, ring):
    a = np.asarray(ring, dtype=np.float64)
    x1, y1, x2, y2 = a[:-1, 0], a[:-1, 1], a[1:, 0], a[1:, 1]
    cross = (y1 > pt[1]) != (y2 > pt[1])
    xi = x1 + (pt[1] - y1) * (x2 - x1) / np.where(y2 != y1, y2 - y1, 1e-300)
    return bool(np.count_nonzero(cross & (pt[0] < xi)) % 2)


def _orient(ring, ccw):
    return ring if (_signed_area(ring) > 0) == ccw else ring[::-1]


def _clip(ring, left, x0=180.0):
    """Sutherland–Hodgman clip of a closed ring to x ≤ x0 (left) or x ≥ x0."""
    inside = (lambda p: p[0] <= x0) if left else (lambda p: p[0] >= x0)
    cut = lambda p, q: [x0, p[1] + (x0 - p[0]) / (q[0] - p[0]) * (q[1] - p[1])]
    pts, out = ring[:-1], []
    for i in range(len(pts)):
        cur, prev = pts[i], pts[i - 1]
        if inside(cur):
            if not inside(prev):
                out.append(cut(prev, cur))
            out.append(cur)
        elif inside(prev):
            out.append(cut(prev, cur))
    return out + [out[0]] if len(out) >= 3 else []


def _split_seam(rings):
    """rings[0] exterior + holes in continuous longitude; split at +180 into ≤2 polygons in [−180, 180]."""
    if max(p[0] for p in rings[0]) <= 180.0:
        return [rings]
    polys = []
    for left in (True, False):
        ext = _clip(rings[0], left)
        if len(ext) < 4:
            continue
        holes = [h for h in (_clip(r, left) for r in rings[1:]) if len(h) >= 4]
        part = [ext] + holes
        if not left:
            part = [[[x - 360.0, y] for x, y in r] for r in part]
        polys.append(part)
    return polys


def _round(poly):
    return [[[round(x, 4), round(y, 4)] for x, y in r] for r in poly]


def contour_features(mask, kind):
    """Land/lake outlines as RFC 7946 (Multi)Polygons: CCW exteriors, CW holes, split at the antimeridian."""
    H, W = mask.shape
    col = int(np.argmin(mask.sum(axis=0)))
    rings = []
    for ring in contours(np.roll(mask, -col, axis=1)):
        pts = [[(x + col + 0.5) / W * 360.0 - 180.0, max(-90.0, min(90.0, 90.0 - (y + 0.5) / H * 180.0))]
               for x, y in ring]
        if len(pts) >= 4 and pts[0] == pts[-1]:
            rings.append(pts)
    depth = [sum(_point_in_ring(r[0], o) for j, o in enumerate(rings) if j != i) for i, r in enumerate(rings)]
    feats = []
    for i, r in enumerate(rings):
        if depth[i] % 2:
            continue
        holes = [_orient(rings[j], False) for j in range(len(rings))
                 if depth[j] == depth[i] + 1 and _point_in_ring(rings[j][0], r)]
        polys = [_round(p) for p in _split_seam([_orient(r, True)] + holes)]
        if len(polys) == 1:
            feats.append(_feature("Polygon", polys[0], {"kind": kind}))
        elif polys:
            feats.append(_feature("MultiPolygon", polys, {"kind": kind}))
    return feats


def _write(path: Path, feats) -> None:
    path.write_text(json.dumps({"type": "FeatureCollection", "features": feats}, separators=(",", ":")))


def write_all(ctx, out_dir: Path, land_raster, lake_raster) -> None:
    g, d = ctx.grid, ctx.data
    gdir = out_dir / "geo"
    gdir.mkdir(parents=True, exist_ok=True)
    _write(gdir / "rivers.geojson", river_lines(g, d["recv"], d["river"], d["strahler"], d["discharge_km3_yr"]))
    _write(gdir / "plate_boundaries.geojson", boundary_features(g, d["plate"], d["bnd_type"]))
    step = max(1, land_raster.shape[1] // 2048)
    _write(gdir / "coast.geojson", contour_features(land_raster[::step, ::step], "land"))
    _write(gdir / "lakes.geojson", contour_features(lake_raster[::step, ::step] & land_raster[::step, ::step], "lake"))