worldgen

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

master

raw · 5383 bytes

"""`mapgen.py --world DIR new-world`: a random starting world (sketch/*.png continents, config/tectonics.toml plates),
so a build runs without drawing anything. Used for the moons; edit or redraw afterwards."""
from __future__ import annotations

from pathlib import Path

import numpy as np
from PIL import Image
from scipy import ndimage

from .noise import fbm, ridged
from .sphere import latlon_to_xyz

W, H = 2000, 1000


def _spread(rng, n, min_deg, avoid=(), max_lat=70.0, tries=4000):
    pts = list(avoid)
    out = []
    for _ in range(tries):
        if len(out) == n:
            break
        lat = np.degrees(np.arcsin(rng.uniform(np.sin(np.radians(-max_lat)), np.sin(np.radians(max_lat)))))
        lon = rng.uniform(-180, 180)
        p = latlon_to_xyz(lat, lon)
        if all(np.degrees(np.arccos(np.clip(p @ latlon_to_xyz(*q), -1, 1))) >= min_deg for q in pts):
            pts.append((lat, lon))
            out.append((float(lat), float(lon)))
    return out


def _centroid(mask, LAT, LON):
    w = np.cos(np.radians(LAT)) * mask
    p = (latlon_to_xyz(LAT, LON) * w[..., None]).sum(axis=(0, 1))
    p /= np.linalg.norm(p)
    return float(np.degrees(np.arcsin(p[2]))), float(np.degrees(np.arctan2(p[1], p[0])))


def make(root: Path, seed: int, continents: int = 6, land_share: float = 0.27, toward=None, ridge: float = 0.86,
         force: bool = False) -> None:
    root = Path(root)
    sk, cfg = root / "sketch", root / "config"
    targets = [sk / "land.png", cfg / "tectonics.toml"]
    if not force and any(p.exists() for p in targets):
        raise SystemExit("new-world: sketch/ or config/tectonics.toml already exist (use --force to overwrite)")
    rng = np.random.default_rng(seed)
    w, h = W // 2, H // 2
    LAT, LON = np.meshgrid(90.0 - (np.arange(h) + 0.5) * 180.0 / h, -180.0 + (np.arange(w) + 0.5) * 360.0 / w,
                           indexing="ij")
    xyz = latlon_to_xyz(LAT, LON).reshape(-1, 3)

    centres = _spread(rng, continents, 35.0, max_lat=55.0)
    field = np.zeros(len(xyz))
    for lat, lon in centres:
        r = np.radians(rng.uniform(18.0, 34.0))
        d = np.arccos(np.clip(xyz @ latlon_to_xyz(lat, lon), -1, 1))
        field = np.maximum(field, np.clip(1.0 - d / r, 0.0, None) * rng.uniform(0.8, 1.2))
    field += 0.55 * fbm(xyz, seed + 7, 6, 2.2)
    if toward is not None:
        field += 1.2 * (xyz @ latlon_to_xyz(*toward))
    area = np.cos(np.radians(LAT)).ravel()
    order = np.argsort(-field)
    cut = field[order][np.searchsorted(np.cumsum(area[order]) / area.sum(), land_share)]
    land = (field > cut).reshape(h, w)

    mount = (ridged(xyz, seed + 11, 5, 3.0) > ridge).reshape(h, w) & ndimage.binary_erosion(land, iterations=6)

    def save(name, m):
        Image.fromarray((m * 255).astype(np.uint8), "L").resize((W, H), Image.BILINEAR).point(
            lambda v: 255 if v > 127 else 0).save(sk / f"{name}.png")

    sk.mkdir(parents=True, exist_ok=True)
    cfg.mkdir(parents=True, exist_ok=True)
    save("land", land)
    save("mountains", mount)
    for name in ("desert", "rainforest", "trench"):
        save(name, np.zeros_like(land))

    lab, n = ndimage.label(land)
    for a, b in zip(lab[:, 0], lab[:, -1]):
        if a and b and a != b:
            lab[lab == b] = a
    sizes = sorted(((int((lab == i).sum()), i) for i in np.unique(lab) if i), reverse=True)
    plates, seeds = [], []
    for k, (px, i) in enumerate(sizes[:continents]):
        m = lab == i
        if px > 0.035 * m.size:                                    # a big landmass: two plates, a collision belt between
            lat, lon = _centroid(m, LAT, LON)
            ang = rng.uniform(0, np.pi)
            side = (np.sin(ang) * (LAT - lat) + np.cos(ang) * ((LON - lon + 180) % 360 - 180) * np.cos(np.radians(lat))) > 0
            parts = [m & side, m & ~side]
        else:
            parts = [m]
        for j, part in enumerate(parts):
            if part.sum() < 50:
                continue
            lat, lon = _centroid(part, LAT, LON)
            seeds.append((lat, lon))
            plates.append((f"continent-{k + 1}{'ab'[j] if len(parts) > 1 else ''}", lat, lon, "continental",
                           rng.uniform(0, 360), rng.uniform(2.0, 5.0)))
    for k, (lat, lon) in enumerate(_spread(rng, 14 - len(plates) // 2, 28.0, seeds, max_lat=85.0)):
        plates.append((f"ocean-{k + 1}", lat, lon, "oceanic", rng.uniform(0, 360), rng.uniform(3.0, 8.0)))

    ocean = np.argwhere(~ndimage.binary_dilation(land, iterations=10))
    def sea_point():
        y, x = ocean[rng.integers(len(ocean))]
        return float(LAT[y, x]), float(LON[y, x])
    f = lambda v: f"[{v[0]:.1f}, {v[1]:.1f}]"
    t = [f"# Generated by `mapgen.py new-world --seed {seed}`. Edit freely (see README.md → Tectonics).",
         "# seed = [lat, lon] where the plate grows from; motion = [azimuth° clockwise from north, speed cm/yr].", ""]
    for pid, lat, lon, kind, az, sp in plates:
        t += ["[[plate]]", f'id = "{pid}"', f"seed = {f((lat, lon))}", f'kind = "{kind}"', f"motion = [{az:.0f}.0, {sp:.1f}]", ""]
    if len(ocean):
        t += ["[[hotspot]]", 'name = "island-chain"', f"center = {f(sea_point())}", "length_km = 600.0", ""]
    (cfg / "tectonics.toml").write_text("\n".join(t))

    print(f"new-world: {len(plates)} plates, sketch/ and config/tectonics.toml written (seed {seed})")