"""Crater meadow (first biome): tileable ground textures for the 3D bridge.

Painted after docs/inspiration/fonds/crater_meadow_0.png: dark, desaturated
olive grass in short strokes, bare brown soil with pebbles and litter, and a
low-frequency mask that decides where the soil shows through.

Every texture tiles: noise is built in the frequency domain (periodic by
construction) and strokes wrap around the edges.

Run headless from big-egg-3d/:
  blender --background --factory-startup --python tools/blender/crater_meadow_textures.py
"""
import bpy, os
import numpy as np

HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT = os.path.dirname(os.path.dirname(HERE))
OUT = os.path.join(PROJECT, "assets", "textures", "biomes", "crater_meadow")
SIZE = 1024
rng = np.random.default_rng(20260929)


def hex_rgb(h):
    h = h.lstrip("#")
    return np.array([int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4)])


def periodic_noise(size, beta, seed):
    """Tileable fractal noise in [0, 1]; larger beta = smoother."""
    r = np.random.default_rng(seed)
    white = r.standard_normal((size, size))
    fx = np.fft.fftfreq(size)[:, None]
    fy = np.fft.fftfreq(size)[None, :]
    f = np.sqrt(fx * fx + fy * fy)
    f[0, 0] = 1.0
    spectrum = np.fft.fft2(white) / f ** beta
    spectrum[0, 0] = 0.0
    n = np.real(np.fft.ifft2(spectrum))
    n -= n.min()
    return n / n.max()


def stamp_strokes(img, count, length, width, colors, jitter, seed, angle_bias=None, alpha=1.0):
    """Short wrapped strokes: grass blades seen from above."""
    r = np.random.default_rng(seed)
    size = img.shape[0]
    x0 = r.uniform(0, size, count)
    y0 = r.uniform(0, size, count)
    ang = r.uniform(0, np.pi * 2, count) if angle_bias is None else r.normal(angle_bias, 0.6, count)
    ln = r.uniform(length[0], length[1], count)
    cidx = r.integers(0, len(colors), count)
    shade = r.uniform(1 - jitter, 1 + jitter, count)
    steps = int(length[1]) + 1
    for s in range(steps):
        t = s / steps
        live = t * ln[:, None][:, 0] <= ln
        # Blades taper: full width at the root, one pixel at the tip.
        w = np.maximum(1, np.round(width * (1.0 - t))).astype(int)
        xs = x0 + np.cos(ang) * ln * t
        ys = y0 + np.sin(ang) * ln * t
        for dx in range(-(width // 2), width // 2 + 1):
            ok = live & (np.abs(dx) < w)
            xi = (np.round(xs[ok] + dx).astype(int)) % size
            yi = (np.round(ys[ok]).astype(int)) % size
            col = colors[cidx[ok]] * shade[ok, None]
            img[yi, xi] = img[yi, xi] * (1 - alpha) + col * alpha


def stamp_blobs(img, count, radius, fill, rim, seed, highlight=None):
    """Pebbles: filled ellipses with a dark rim and a lit top edge."""
    r = np.random.default_rng(seed)
    size = img.shape[0]
    for _ in range(count):
        cx, cy = r.uniform(0, size, 2)
        rx = r.uniform(*radius)
        ry = rx * r.uniform(0.6, 1.0)
        rot = r.uniform(0, np.pi)
        c = fill * r.uniform(0.8, 1.15)
        R = int(rx + 3)
        yy, xx = np.mgrid[-R:R + 1, -R:R + 1]
        u = (xx * np.cos(rot) + yy * np.sin(rot)) / rx
        v = (-xx * np.sin(rot) + yy * np.cos(rot)) / ry
        d = np.sqrt(u * u + v * v)
        xi = (int(cx) + xx) % size
        yi = (int(cy) + yy) % size
        body = d <= 1.0
        edge = (d > 1.0) & (d <= 1.0 + 1.6 / rx)
        img[yi[edge], xi[edge]] = rim
        img[yi[body], xi[body]] = c
        if highlight is not None:
            top = body & (v < -0.35) & (d > 0.45)
            img[yi[top], xi[top]] = img[yi[top], xi[top]] * 0.4 + highlight * 0.6


def save(name, rgb, alpha=None):
    size = rgb.shape[0]
    rgba = np.ones((size, size, 4), dtype=np.float32)
    rgba[..., :3] = np.clip(rgb, 0, 1)
    if alpha is not None:
        rgba[..., 3] = alpha
    image = bpy.data.images.new(name, size, size, alpha=alpha is not None)
    # Blender stores rows bottom-up.
    image.pixels.foreach_set(rgba[::-1].ravel())
    image.filepath_raw = os.path.join(OUT, name + ".png")
    image.file_format = "PNG"
    image.save()
    print("SAVED", image.filepath_raw)


def grass():
    base = hex_rgb("#2c3521")
    n1 = periodic_noise(SIZE, 1.6, 1)[..., None]
    n2 = periodic_noise(SIZE, 1.0, 2)[..., None]
    img = base * (0.78 + 0.45 * n1) * (0.9 + 0.2 * n2)
    # Olive-to-grey drift: the reference grass is dusty, not lush.
    grey = hex_rgb("#3a3d33")
    img = img * (1 - 0.35 * n2) + grey * 0.35 * n2
    blades = np.array([hex_rgb(h) for h in ("#1b2214", "#242d18", "#3f4b2a", "#4f5b33", "#5d6a3c", "#6b7446")])
    stamp_strokes(img, 26000, (5, 12), 3, blades[:3], 0.15, 3)
    stamp_strokes(img, 22000, (4, 10), 2, blades[2:], 0.18, 4)
    stamp_strokes(img, 5000, (3, 7), 2, blades[4:], 0.12, 5)
    # A few dead, yellowed blades and dark specks of soil between tufts.
    stamp_strokes(img, 1800, (4, 9), 2, np.array([hex_rgb("#7b7442"), hex_rgb("#6a5d38")]), 0.1, 6)
    specks = rng.random((SIZE, SIZE)) < 0.004
    img[specks] = hex_rgb("#1a1712")
    # The reference grass reads grey-olive: pull 45 % of the way to luminance.
    luma = (img @ np.array([0.299, 0.587, 0.114]))[..., None]
    img = img * 0.55 + luma * 0.45
    save("crater_grass", img)


def dirt():
    base = hex_rgb("#3b3026")
    n1 = periodic_noise(SIZE, 1.4, 11)[..., None]
    n2 = periodic_noise(SIZE, 0.7, 12)[..., None]
    img = base * (0.7 + 0.55 * n1) * (0.88 + 0.24 * n2)
    ash = hex_rgb("#2a2622")
    img = img * (1 - 0.4 * (1 - n1)) + ash * 0.4 * (1 - n1)
    stamp_blobs(img, 520, (1.8, 4.2), hex_rgb("#6d6a61"), hex_rgb("#1d1a16"), 13, hex_rgb("#9a968a"))
    stamp_blobs(img, 90, (4.5, 8.0), hex_rgb("#5e5c55"), hex_rgb("#1a1714"), 14, hex_rgb("#8c897f"))
    # Litter: pale paper flecks, rusty and red bits, a few stray blades.
    r = np.random.default_rng(15)
    for color, count in (("#b9b3a0", 260), ("#8a4b2e", 160), ("#9c2f2a", 70), ("#c9c2ad", 90)):
        xs = r.integers(0, SIZE, count)
        ys = r.integers(0, SIZE, count)
        for dx in range(2):
            for dy in range(2):
                img[(ys + dy) % SIZE, (xs + dx) % SIZE] = hex_rgb(color) * r.uniform(0.85, 1.1)
    stamp_strokes(img, 2400, (3, 8), 2, np.array([hex_rgb("#3f4b2a"), hex_rgb("#56613a")]), 0.15, 16)
    save("crater_dirt", img)


def patch_mask():
    """Where soil breaks through: soft blobs, tileable, sampled at large scale."""
    n = periodic_noise(512, 2.1, 21) * 0.75 + periodic_noise(512, 1.3, 22) * 0.25
    n = (n - n.min()) / (n.max() - n.min())
    save_gray("crater_patches", n)


def save_gray(name, g):
    size = g.shape[0]
    rgb = np.repeat(g[..., None], 3, axis=2)
    save(name, rgb)


if __name__ == "__main__":
    os.makedirs(OUT, exist_ok=True)
    grass()
    dirt()
    patch_mask()
