"""Stage `sketch`: sample the sketch + user masks onto cells.""" from __future__ import annotations from pathlib import Path import numpy as np from PIL import Image from . import zones as ZN from .config import params from .noise import fbm from .pipeline import StageError from .sphere import latlon_to_xyz, xyz_to_latlon W, H = 2000, 1000 SKETCH = ("land", "mountains", "desert", "rainforest", "trench") MASKS = ("land_hint", "mountain_hint", "o2_zones", "gravity_zones", "lock") DEFAULTS = {"warp_km": 1000.0, "warp_freq": 1.2, "detail_warp_km": 200.0, "detail_warp_freq": 5.0, "moves": []} def load_png01(path: Path) -> np.ndarray: return np.array(Image.open(path).convert("L"), dtype=np.float64) / 255.0 def load_mask(path: Path) -> np.ndarray: """Grayscale override mask → [−1, 1]; mid-grey (128 / 32768), transparency and missing detail = neutral 0.""" try: im = Image.open(path) im.load() except (OSError, ValueError) as e: raise StageError(f"cannot read mask {path.name}: {e}") from e if im.mode in ("I", "I;16", "I;16B", "I;16L"): v = (np.array(im).astype(np.float64) - 32768.0) / 32768.0 dead = 1.0 / 32768.0 else: la = np.array(im.convert("LA")).astype(np.float64) v = np.where(la[..., 1] > 0, (la[..., 0] - 127.5) / 127.5, 0.0) dead = 1.0 / 255.0 return np.clip(np.where(np.abs(v) <= dead, 0.0, v), -1.0, 1.0) def sample_equirect(img: np.ndarray, lat, lon) -> np.ndarray: h, w = img.shape x = (np.asarray(lon, dtype=np.float64) + 180.0) / 360.0 * w - 0.5 y = (90.0 - np.asarray(lat, dtype=np.float64)) / 180.0 * h - 0.5 x0 = np.floor(x).astype(np.int64) y0 = np.floor(y).astype(np.int64) fx, fy = x - x0, y - y0 xa, xb = x0 % w, (x0 + 1) % w ya, yb = np.clip(y0, 0, h - 1), np.clip(y0 + 1, 0, h - 1) top = img[ya, xa] * (1 - fx) + img[ya, xb] * fx bot = img[yb, xa] * (1 - fx) + img[yb, xb] * fx return top * (1 - fy) + bot * fy def rotation(a, b): """3×3 rotation carrying [lat, lon] a onto b along the great circle (Rodrigues).""" pa, pb = latlon_to_xyz(*np.array(a, dtype=np.float64)), latlon_to_xyz(*np.array(b, dtype=np.float64)) k = np.cross(pa, pb) s, c = np.linalg.norm(k), float(np.dot(pa, pb)) if s < 1e-12: return np.eye(3) k /= s K = np.array([[0, -k[2], k[1]], [k[2], 0, -k[0]], [-k[1], k[0], 0]]) return np.eye(3) + s * K + (1 - c) * K @ K def continent_mask(land, lat, lon): """Pixels of the sketch landmass containing (lat, lon), 2 px coastal fringe included; lon wraps.""" from scipy import ndimage lab, _ = ndimage.label(land) for a, b in zip(lab[:, 0], lab[:, -1]): if a and b and a != b: lab[lab == b] = a h, w = land.shape y, x = min(max(int((90.0 - lat) / 180.0 * h), 0), h - 1), int((lon + 180.0) / 360.0 * w) % w if not lab[y, x]: raise StageError(f"sketch move: no sketch land at [{lat}, {lon}]") return ndimage.binary_dilation(lab == lab[y, x], iterations=2) def warped_latlon(g, P, seed): """Sample positions displaced by a two-scale domain warp (RMS ≈ warp_km, detail_warp_km).""" p = g.xyz.copy() for amp, freq, s in ((P["warp_km"], P["warp_freq"], 201), (P["detail_warp_km"], P["detail_warp_freq"], 211)): if amp <= 0: continue disp = np.stack([fbm(g.xyz, seed + s + k, 4, freq) for k in range(3)], axis=1) p = p + (amp / g.radius_km) * disp / max(float(disp.std()), 1e-12) return xyz_to_latlon(p) def run(ctx) -> dict: g = ctx.grid P = params(ctx.cfg, "sketch", DEFAULTS) sk = ctx.root / "sketch" missing = [n for n in SKETCH if not (sk / f"{n}.png").exists()] if missing: raise StageError(f"sketch files missing {missing}: draw them or run `mapgen.py new-world` first") lat, lon = warped_latlon(g, P, ctx.seed) # the drawing is a loose guide: warp it (user masks are not) imgs = {n: load_png01(sk / f"{n}.png") for n in SKETCH} moved = [] # [[sketch.moves]]: rotate whole landmasses across the sphere for mv in P["moves"]: comp = continent_mask(imgs["land"] > 0.5, *mv["at"]) moved.append((rotation(mv["at"], mv["to"]), {n: np.where(comp, im, 0.0) for n, im in imgs.items()})) imgs = {n: np.where(comp, 0.0, im) for n, im in imgs.items()} p = latlon_to_xyz(lat, lon) out = {} for n in SKETCH: v = sample_equirect(imgs[n], lat, lon) for rot, parts in moved: qlat, qlon = xyz_to_latlon(p @ rot) # rot.T applied to row vectors v = np.maximum(v, sample_equirect(parts[n], qlat, qlon)) out[f"sk_{n}"] = v for m in MASKS: p = ctx.root / "masks" / f"{m}.png" out[f"m_{m}"] = sample_equirect(load_mask(p), g.lat, g.lon) if p.exists() else np.zeros(g.n) zo2, zg = ZN.masks(g.xyz, ctx.tect.get("zone", []), ctx.seed, g.radius_km) # config zones on top of the masks out["m_o2_zones"] = np.clip(out["m_o2_zones"] + zo2, -1.0, 1.0) out["m_gravity_zones"] = np.clip(out["m_gravity_zones"] + zg, -1.0, 1.0) return out