#!/usr/bin/env python3
"""
花园画师 (garden_painter.py) — 第 26 轮:给花园画第一幅画。

不是 AI 生成,是代码亲手画的:每一笔都来自花园自己的 lore——
  22×22 迷宫(第 22 轮,递归回溯,484 个房间)变成画里的树篱花圃;
  13 根铜管(第 21 轮的风铃)挂在画里的老树枝上;
  一炉面包(第 24 轮,冷藏 24 小时)摆在木桌上、灯笼旁边;
  37 颗星 45 条弦(第 25 轮的星图)在夜空里连成星座;
  种子 26 = 本轮编号。颜料是数字,画布是 assert。

用法:
  python3 garden_painter.py            # 渲染 content/notes/garden-painting.png

验证(花园传统:想当然必被打脸):
  - 迷宫是完美迷宫: 树边数 == 484−1, BFS 全可达
  - 星座连通且恰好 45 条弦, 37 颗星每颗都有连线
  - 风铃 13 根管子, 夜空 37 颗星
  - 输出尺寸 == 1024×768, 像素抽查(夜空是深青色、灯笼是暖色)
"""
import math
import random
from collections import deque

from PIL import Image, ImageDraw

# ---------------------------------------------------------------- 常量
W, H = 2048, 1536          # 2× 超采样画布,最后缩小到 1024×768
FINAL = (1024, 768)
SEED = 26                  # 本轮编号

SKY_TOP = (6, 20, 32)          # 深青夜色
SKY_HORIZON = (38, 74, 74)     # 地平线附近的青绿
GLOW_BAND = (138, 90, 51)      # 灯笼的暖光染到天边
HILL_FAR = (27, 58, 48)
HILL_NEAR = (20, 44, 38)
GROUND = (23, 48, 31)
GROUND_SPECK = (29, 58, 38)
GRASS_DARK = (22, 42, 30)
HEDGE = (46, 107, 62)
HEDGE_HI = (63, 138, 79)
FRAME = (107, 68, 35)
FRAME_DK = (74, 47, 24)
TRUNK = (74, 50, 32)
TUBE = (217, 164, 65)
TUBE_HI = (240, 200, 106)
BREAD = (201, 138, 61)
BREAD_DK = (165, 106, 40)
TABLE = (122, 82, 44)
TABLE_DK = (88, 59, 32)
LANTERN = (245, 199, 106)
FLAME = (255, 217, 138)
STAR = (242, 210, 122)
CHORD = (201, 166, 74)
MOON = (239, 227, 192)


def lerp(a, b, t):
    return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))


def solve8(rows):
    """8×8 高斯消元(部分主元),解透视变换系数。"""
    n = 8
    for col in range(n):
        piv = max(range(col, n), key=lambda r: abs(rows[r][col]))
        rows[col], rows[piv] = rows[piv], rows[col]
        pv = rows[col][col]
        rows[col] = [v / pv for v in rows[col]]
        for r in range(n):
            if r != col and rows[r][col]:
                f = rows[r][col]
                rows[r] = [a - f * b for a, b in zip(rows[r], rows[col])]
    return [rows[i][n] for i in range(n)]


def perspective_coeffs(quad_a, quad_b):
    """求把 quad_a 的四个角映射到 quad_b 对应角的透视系数(求解线性方程组)。"""
    rows = []
    for (x, y), (u, v) in zip(quad_a, quad_b):
        rows.append([x, y, 1, 0, 0, 0, -x * u, -y * u, u])
        rows.append([0, 0, 0, x, y, 1, -x * v, -y * v, v])
    return solve8(rows)


def make_maze(w, h, rng):
    """递归回溯(迭代 DFS)生成完美迷宫,返回墙集合(墙 = 没被选进生成树的边)。"""
    tree = set()
    visited = {(rng.randrange(w), rng.randrange(h))}
    stack = [next(iter(visited))]
    while stack:
        cx, cy = stack[-1]
        nbs = [(cx + dx, cy + dy) for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))
               if 0 <= cx + dx < w and 0 <= cy + dy < h and (cx + dx, cy + dy) not in visited]
        if not nbs:
            stack.pop()
            continue
        nx, ny = rng.choice(nbs)
        tree.add(frozenset(((cx, cy), (nx, ny))))    # 走进新格子的那条边,进了生成树
        visited.add((nx, ny))
        stack.append((nx, ny))
    all_edges = {frozenset(((x, y), (x + dx, y + dy)))
                 for x in range(w) for y in range(h)
                 for dx, dy in ((1, 0), (0, 1))
                 if x + dx < w and y + dy < h}
    assert len(tree) == w * h - 1, "生成树必须有 n−1 条边"
    return all_edges - tree


def maze_stats(w, h, walls):
    """BFS 求全可达、死胡同数、直径(两次 BFS)。"""
    def adj(p):
        x, y = p
        for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
            q = (x + dx, y + dy)
            if 0 <= q[0] < w and 0 <= q[1] < h and frozenset((p, q)) not in walls:
                yield q

    def bfs(s):
        dist = {s: 0}
        q = deque([s])
        while q:
            p = q.popleft()
            for nxt in adj(p):
                if nxt not in dist:
                    dist[nxt] = dist[p] + 1
                    q.append(nxt)
        far = max(dist, key=dist.get)
        return dist, far

    dist, far = bfs((0, 0))
    assert len(dist) == w * h, "迷宫必须全可达(完美迷宫性质)"
    _, far2 = bfs(far)
    d2, _ = bfs(far2)
    dead = sum(1 for x in range(w) for y in range(h)
               if sum(1 for _ in adj((x, y))) == 1)
    return len(dist) == w * h, dead, max(d2.values())


def paint_maze_patch(w, h, walls, rng, cell=14):
    """把迷宫画成一块带木框的树篱花圃(俯视)。"""
    pad = 12
    size = pad * 2 + w * cell
    img = Image.new("RGB", (size, size), GRASS_DARK)
    d = ImageDraw.Draw(img)
    # 树篱墙:两格之间没被选进生成树的那条边,就是一道树篱
    for (x1, y1), (x2, y2) in walls:
        cx1, cy1 = pad + x1 * cell + cell // 2, pad + y1 * cell + cell // 2
        cx2, cy2 = pad + x2 * cell + cell // 2, pad + y2 * cell + cell // 2
        d.rounded_rectangle([min(cx1, cx2) - 4, min(cy1, cy2) - 4,
                             max(cx1, cx2) + 4, max(cy1, cy2) + 4],
                            radius=5, fill=HEDGE)
        d.rounded_rectangle([min(cx1, cx2) - 4, min(cy1, cy2) - 4,
                             max(cx1, cx2) + 4, min(cy1, cy2) - 1],
                            radius=4, fill=HEDGE_HI)
    # 外圈树篱(迷宫边界全是墙)
    d.rectangle([pad - 3, pad - 3, size - pad + 3, size - pad + 3],
                outline=HEDGE, width=6)
    # 花:在随机空地上撒几朵暖色小花
    open_cells = [(x, y) for x in range(w) for y in range(h)
                  if (x == 0 or (x - 1, y) in walls) and rng.random() < 0.06]
    for x, y in rng.sample(open_cells, min(9, len(open_cells))):
        cx, cy = pad + x * cell + cell // 2, pad + y * cell + cell // 2
        d.ellipse([cx - 3, cy - 3, cx + 3, cy + 3], fill=rng.choice(((232, 161, 74), (217, 106, 138))))
    # 木框
    d.rectangle([0, 0, size - 1, size - 1], outline=FRAME_DK, width=6)
    d.rectangle([3, 3, size - 4, size - 4], outline=FRAME, width=5)
    return img


def draw_sky(base, rng):
    """夜空渐变 + 月亮 + 37 星 45 弦星座。"""
    d = ImageDraw.Draw(base)
    for y in range(H):
        t = y / 900 if y < 900 else 1.0
        c = lerp(SKY_TOP, SKY_HORIZON, t)
        if y > 760:
            c = lerp(c, GLOW_BAND, (y - 760) / 140)
        d.line([(0, y), (W, y)], fill=c)
    # 月亮 + 光晕
    glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
    gd = ImageDraw.Draw(glow)
    mx, my, mr = 1560, 250, 90
    for i in range(24, 0, -1):
        a = int(14 * (1 - i / 24) ** 2)
        r = mr + i * 9
        gd.ellipse([mx - r, my - r, mx + r, my + r], fill=(MOON[0], MOON[1], MOON[2], a))
    gd.ellipse([mx - mr, my - mr, mx + mr, my + mr], fill=MOON + (255,))
    base.alpha_composite(glow)
    # 星星
    rng = random.Random(SEED)
    pts = []
    while len(pts) < 37:
        x = rng.uniform(60, W - 60)
        y = rng.uniform(70, 760)
        if (x - mx) ** 2 + (y - my) ** 2 < (mr + 40) ** 2:
            continue
        pts.append((x, y))
    chords = constellation(pts, total=45)
    glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
    gd = ImageDraw.Draw(glow)
    for i, j in chords:
        gd.line([pts[i], pts[j]], fill=CHORD + (110,), width=2)
    base.alpha_composite(glow)
    d = ImageDraw.Draw(base)
    for x, y in pts:
        r = 3 if rng.random() < 0.3 else 2
        d.ellipse([x - r, y - r, x + r, y + r], fill=STAR)
        if r == 3:  # 亮星加十字光芒
            d.line([(x - 7, y), (x + 7, y)], fill=STAR)
            d.line([(x, y - 7), (x, y + 7)], fill=STAR)
    return pts, chords


def constellation(pts, total=45):
    """每颗星连最近 3 邻,先保证全连通(并查集),再按长度补足到 total 条弦。"""
    n = len(pts)

    def d2(a, b):
        return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2

    cands = set()
    for i, p in enumerate(pts):
        for _, j in sorted((d2(p, q), j) for j, q in enumerate(pts) if j != i)[:3]:
            cands.add(tuple(sorted((i, j))))
    cands = sorted(cands, key=lambda e: d2(pts[e[0]], pts[e[1]]))
    parent = list(range(n))

    def find(x):
        while parent[x] != x:
            parent[x] = parent[parent[x]]
            x = parent[x]
        return x

    chosen = []
    for a, b in cands:
        if find(a) != find(b):
            parent[find(a)] = find(b)
            chosen.append((a, b))
        if len(chosen) == n - 1:
            break
    # 兜底:若候选图不连通,补最短跨分量边
    while len(chosen) < n - 1:
        best = min(((i, j) for i in range(n) for j in range(i + 1, n)
                    if find(i) != find(j)),
                   key=lambda e: d2(pts[e[0]], pts[e[1]]))
        parent[find(best[0])] = find(best[1])
        chosen.append(best)
    for e in cands:
        if len(chosen) >= total:
            break
        if e not in chosen:
            chosen.append(e)
    assert len(chosen) == total, f"星座弦数应为 {total},实得 {len(chosen)}"
    deg = [0] * n
    for a, b in chosen:
        deg[a] += 1
        deg[b] += 1
    assert all(deg), "每颗星都必须有连线"
    return chosen


def draw_scenery(base):
    """远山、地面、树、风铃、桌子、面包、灯笼。"""
    d = ImageDraw.Draw(base)
    # 远山与地面
    d.polygon([(0, 870), (300, 820), (640, 880), (1000, 830), (1400, 890),
               (1800, 840), (W, 890), (W, H), (0, H)], fill=HILL_FAR)
    d.polygon([(0, 960), (420, 900), (860, 950), (1300, 905), (1750, 960),
               (W, 930), (W, H), (0, H)], fill=HILL_NEAR)
    d.rectangle([0, 980, W, H], fill=GROUND)
    rng = random.Random(SEED * 7)
    for _ in range(2600):
        x, y = rng.uniform(0, W), rng.uniform(985, H)
        d.ellipse([x, y, x + rng.uniform(1, 3), y + rng.uniform(1, 3)],
                  fill=GROUND_SPECK)
    # 老树
    d.line([(1330, 1350), (1320, 760)], fill=TRUNK, width=26)
    d.line([(1322, 860), (1180, 730)], fill=TRUNK, width=14)      # 挂风铃的枝
    d.line([(1320, 1000), (1460, 900)], fill=TRUNK, width=12)
    d.line([(1318, 780), (1240, 690)], fill=TRUNK, width=8)
    # 风铃:横杆 + 13 根铜管(长度按五声音阶骨架随机,种子固定)
    rng = random.Random(SEED * 13)
    bx, by = 1180, 730
    d.line([(bx - 60, by), (bx + 60, by)], fill=(58, 42, 26), width=8)
    tubes = []
    for k in range(13):
        tlen = int(rng.uniform(55, 130))
        tx = bx - 52 + k * 8.7
        ty = by + 6
        d.rounded_rectangle([tx - 3, ty, tx + 3, ty + tlen], radius=3, fill=TUBE)
        d.line([(tx - 1, ty), (tx - 1, ty + tlen)], fill=TUBE_HI)
        tubes.append(tlen)
    assert len(tubes) == 13, "风铃必须有 13 根管子"
    # 木桌 + 面包
    d.rectangle([1590, 1240, 1930, 1290], fill=TABLE)
    d.rectangle([1596, 1246, 1924, 1284], fill=TABLE_DK)
    d.rectangle([1610, 1290, 1640, 1410], fill=TABLE_DK)
    d.rectangle([1880, 1290, 1910, 1410], fill=TABLE_DK)
    d.ellipse([1650, 1195, 1810, 1305], fill=BREAD)               # 面包
    d.arc([1670, 1220, 1790, 1290], 200, 340, fill=BREAD_DK, width=4)
    d.arc([1690, 1210, 1770, 1290], 30, 150, fill=BREAD_DK, width=4)
    # 灯笼 + 暖光
    glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
    gd = ImageDraw.Draw(glow)
    lx, ly = 1860, 1150
    for i in range(40, 0, -1):
        a = int(10 * (1 - i / 40) ** 2)
        r = i * 12
        gd.ellipse([lx - r, ly - r, lx + r, ly + r], fill=(255, 178, 92, a))
    base.alpha_composite(glow)
    d = ImageDraw.Draw(base)
    d.rounded_rectangle([lx - 28, ly - 12, lx + 28, ly + 60], radius=8,
                        fill=LANTERN, outline=(176, 128, 60), width=3)
    d.polygon([(lx, ly - 34), (lx - 12, ly - 8), (lx + 12, ly - 8)], fill=(120, 84, 44))
    d.ellipse([lx - 7, ly + 6, lx + 7, ly + 20], fill=FLAME)
    d.ellipse([lx - 3, ly + 10, lx + 3, ly + 16], fill=(255, 244, 214))
    return tubes


def main():
    rng = random.Random(SEED)
    # 迷宫(与第 22 轮同款算法,种子=本轮编号)
    walls = make_maze(22, 22, rng)
    ok, dead, diam = maze_stats(22, 22, walls)
    assert ok and len(walls) == 2 * 22 * 22 - 44 - (22 * 22 - 1), \
        "墙数 == 总边数 − 树边数(22×22 网格共 924 条边,树 483 条,墙 441 条)"
    patch = paint_maze_patch(22, 22, walls, rng)
    # 透视:把方形花圃映射到地面上的梯形(近宽远窄)
    pw, ph = patch.size
    src = [(0, 0), (pw, 0), (0, ph), (pw, ph)]
    dst = [(330, 975), (770, 970), (120, 1430), (990, 1445)]
    bx0, by0 = min(p[0] for p in dst), min(p[1] for p in dst)
    bx1, by1 = max(p[0] for p in dst), max(p[1] for p in dst)
    # 注意:PIL 的透视变换在输出图像的局部坐标上采样(0..w, 0..h),
    # 系数必须用「局部目标四边形 → 源正方形」求解,否则全部采样越界变黑
    dst_local = [(x - bx0, y - by0) for (x, y) in dst]
    coeffs = perspective_coeffs(dst_local, src)
    warped = patch.convert("RGBA").transform(
        (bx1 - bx0, by1 - by0), Image.PERSPECTIVE, coeffs,
        resample=Image.BILINEAR, fillcolor=(0, 0, 0, 0))
    # 校验:目标四角(局部坐标)经系数映射必须回到源四角,误差 < 2px
    for (sx, sy), (dx, dy) in zip(src, dst_local):
        den = coeffs[6] * dx + coeffs[7] * dy + 1
        mx_ = (coeffs[0] * dx + coeffs[1] * dy + coeffs[2]) / den
        my_ = (coeffs[3] * dx + coeffs[4] * dy + coeffs[5]) / den
        assert abs(mx_ - sx) < 2 and abs(my_ - sy) < 2, "透视系数校验失败"

    canvas = Image.new("RGBA", (W, H))
    pts, chords = draw_sky(canvas, rng)
    draw_scenery(canvas)
    # 梯形外是透明(bbox 四角三角形),alpha_composite 让地面透出来
    canvas.alpha_composite(warped, (bx0, by0))
    # 地面加一层极淡的雾气,让花圃"坐"进地里
    fog = Image.new("RGBA", (W, H), (0, 0, 0, 0))
    fd = ImageDraw.Draw(fog)
    for y in range(1400, H):
        fd.line([(0, y), (W, y)], fill=(38, 74, 74, int(26 * (y - 1400) / 136)))
    canvas = Image.alpha_composite(canvas.convert("RGBA"), fog).convert("RGB")

    out = canvas.resize(FINAL, Image.LANCZOS)
    path = "content/notes/garden-painting.png"
    out.save(path)
    # 像素抽查:夜空深青(蓝 > 红)、灯笼暖(红 > 蓝)
    sky_px = out.getpixel((400, 60))
    lamp_px = out.getpixel((930, 575))
    assert sky_px[2] > sky_px[0], f"夜空应偏蓝,实得 {sky_px}"
    assert lamp_px[0] > lamp_px[2], f"灯笼应偏暖,实得 {lamp_px}"
    assert out.size == FINAL
    print(f"画布 1024×768 完成: 迷宫 22×22(死胡同 {dead}、直径 {diam} 步),"
          f"风铃 13 管,星星 {len(pts)} 颗 / 弦 {len(chords)} 条,种子 {SEED}")
    print(f"已保存 {path}")


if __name__ == "__main__":
    main()
