# Draws the impact particle atlases for ImpactFx3D (Sheet row 67).
# One 2x2 atlas per family: four shapes, one picked at random per particle.
# White/grey shapes on transparent background, tinted in game.
# Usage: python tools/dev/draw_impact_particles.py assets/textures/impact [family ...]
import math
import random
import sys

import numpy as np
from PIL import Image, ImageDraw, ImageFilter

OUT = sys.argv[1]
ONLY = set(sys.argv[2:])
SS = 4  # supersampling


# --- Helpers -------------------------------------------------------------------

class Frame:
    def __init__(self, size):
        self.size = size
        self.big = size * SS
        self.mask = Image.new('L', (self.big, self.big), 0)
        self.dark = Image.new('L', (self.big, self.big), 0)
        self.light = Image.new('L', (self.big, self.big), 0)
        self.m = ImageDraw.Draw(self.mask)
        self.d = ImageDraw.Draw(self.dark)
        self.l = ImageDraw.Draw(self.light)

    def px(self, v):
        # Fraction of the frame to supersampled pixels.
        return v * self.big


def rot(points, angle, cx, cy):
    c, s = math.cos(angle), math.sin(angle)
    return [(cx + (x - cx) * c - (y - cy) * s, cy + (x - cx) * s + (y - cy) * c) for x, y in points]


def ellipse(cx, cy, rx, ry, angle=0.0, n=48):
    pts = [(cx + math.cos(t) * rx, cy + math.sin(t) * ry) for t in (i / n * math.tau for i in range(n))]
    return rot(pts, angle, cx, cy)


def polar(cx, cy, r, rng, lobes=4, amp=0.1, n=180):
    phases = [(k, rng.uniform(0, math.tau), rng.uniform(0.3, 1.0) * amp / (1 + k * 0.4)) for k in range(2, lobes + 2)]
    pts = []
    for i in range(n):
        a = i / n * math.tau
        rr = r * (1 + sum(am * math.sin(k * a + ph) for k, ph, am in phases))
        pts.append((cx + math.cos(a) * rr, cy + math.sin(a) * rr))
    return pts


def stroke(draw, pts, w0, w1, fill=255):
    # A polyline whose width tapers from w0 to w1, drawn as quads and joints.
    n = len(pts) - 1
    for i in range(n):
        (x0, y0), (x1, y1) = pts[i], pts[i + 1]
        wa = w0 + (w1 - w0) * i / n
        wb = w0 + (w1 - w0) * (i + 1) / n
        dx, dy = x1 - x0, y1 - y0
        ln = math.hypot(dx, dy) or 1
        nx, ny = -dy / ln, dx / ln
        draw.polygon([(x0 + nx * wa / 2, y0 + ny * wa / 2), (x1 + nx * wb / 2, y1 + ny * wb / 2),
                      (x1 - nx * wb / 2, y1 - ny * wb / 2), (x0 - nx * wa / 2, y0 - ny * wa / 2)], fill=fill)
        draw.ellipse((x1 - wb / 2, y1 - wb / 2, x1 + wb / 2, y1 + wb / 2), fill=fill)


def bezier(p0, p1, p2, n=24):
    return [((1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * p1[0] + t * t * p2[0],
             (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * p1[1] + t * t * p2[1]) for t in (i / n for i in range(n + 1))]


def jagged(a, b, rng, steps, amp):
    # Points from a to b with a random sideways walk: a torn or broken edge.
    pts = []
    dx, dy = b[0] - a[0], b[1] - a[1]
    ln = math.hypot(dx, dy) or 1
    nx, ny = -dy / ln, dx / ln
    for i in range(steps + 1):
        t = i / steps
        o = 0 if i in (0, steps) else rng.uniform(-amp, amp)
        pts.append((a[0] + dx * t + nx * o, a[1] + dy * t + ny * o))
    return pts


def melt(img, radius):
    return img.filter(ImageFilter.GaussianBlur(radius * SS)).point(lambda v: 255 if v >= 128 else 0)


def arr(img):
    return np.asarray(img).astype(np.float32) / 255.0


def value_noise(size, cells, rng):
    grid = rng.random((cells + 1, cells + 1)).astype(np.float32)
    img = Image.fromarray((grid * 255).astype(np.uint8)).resize((size, size), Image.BICUBIC)
    return arr(img)


# --- Compositing ---------------------------------------------------------------

def compose(frame, style):
    size = frame.size
    mask = frame.mask.resize((size, size), Image.LANCZOS)
    dark = arr(frame.dark.resize((size, size), Image.LANCZOS))
    light = arr(frame.light.resize((size, size), Image.LANCZOS))
    a = arr(mask)
    if style.get('glow'):
        # Additive families: a white core and a soft halo around it.
        halo = arr(mask.filter(ImageFilter.GaussianBlur(size * style.get('halo', 0.06))))
        alpha = np.clip(np.maximum(a, halo * style.get('halo_strength', 0.75)), 0, 1)
        tone = np.clip(0.55 + 0.45 * a + light, 0, 1)
    else:
        inner = arr(mask.filter(ImageFilter.GaussianBlur(max(1.0, size * style.get('depth', 0.07)))))
        gy, gx = np.gradient(inner)
        lit = -(gx * -0.6 + gy * -0.8)
        peak = np.abs(lit).max() or 1.0
        lit = lit / peak
        tone = style.get('base', 0.95) * (0.74 + 0.26 * inner) + style.get('relief', 0.2) * lit
        tone = tone * (1.0 - style.get('dark_strength', 0.45) * dark)
        tone = tone + (1.0 - tone) * light * style.get('light_strength', 0.9)
        tone = np.clip(tone, 0, 1)
        alpha = a * style.get('opacity', 1.0)
        if style.get('edge_alpha'):
            # Glassy: the body is see-through, the rim and highlights are not.
            rim = np.clip(a - inner * 1.1, 0, 1) * 3.0
            alpha = np.clip(a * style['opacity'] + rim + light * 0.8, 0, 1) * a
    rgba = np.dstack([tone, tone, tone, alpha])
    return Image.fromarray((np.clip(rgba, 0, 1) * 255).astype(np.uint8), 'RGBA')


def soft_frame(size, rng, puffs, spread, density, shade):
    # Dust and smoke: overlapping soft blobs broken up by noise.
    yy, xx = np.mgrid[0:size, 0:size].astype(np.float32) / size
    field = np.zeros((size, size), np.float32)
    # Small puffs sown along a wandering path: a cloud with curls, not a ball.
    x, y = 0.5, 0.5
    heading = rng.uniform(0, math.tau)
    for k in range(puffs):
        heading += rng.uniform(-1.4, 1.4)
        step = rng.uniform(0.04, 0.09)
        x = min(0.78, max(0.22, x + math.cos(heading) * step))
        y = min(0.78, max(0.22, y + math.sin(heading) * step))
        r = rng.uniform(0.05, 0.11)
        field += rng.uniform(0.6, 1.0) * np.exp(-((xx - x) ** 2 + (yy - y) ** 2) / (2 * r * r))
    noise = 0.5 * value_noise(size, 6, rng) + 0.3 * value_noise(size, 13, rng) + 0.2 * value_noise(size, 27, rng)
    field = field * (0.15 + 1.6 * noise)
    border = np.minimum(np.minimum(xx, 1 - xx), np.minimum(yy, 1 - yy))
    edge = np.clip(border / 0.12, 0, 1)
    alpha = np.clip((field - 0.3) * density, 0, 1) ** 1.6 * 0.8 * edge
    # A soft rim: a cloud thins out, it has no edge.
    alpha = arr(Image.fromarray((alpha * 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(size * 0.025)))
    tone = np.clip(0.95 - shade * (yy - 0.3) - 0.15 * (1 - noise), 0.4, 1)
    rgba = np.dstack([tone, tone, tone, alpha])
    return Image.fromarray((np.clip(rgba, 0, 1) * 255).astype(np.uint8), 'RGBA')


# --- Families -------------------------------------------------------------------
# Each drawer fills a Frame (mask + dark/light details) for variant i.
# Convention for "aligned" families: the image top trails the motion.

def drops(f, rng, i):
    tails = [0.32, 0.42, 0.24, 0.30]
    c = f.big / 2
    r = f.px([0.17, 0.20, 0.13, 0.15][i])
    head_y = f.px(0.64)
    pts = []
    for k in range(120):
        t = k / 120 * math.tau
        x = math.sin(t) * r
        y = math.cos(t)
        if y >= 0:
            py = head_y + y * r
        else:
            py = head_y + y * f.px(tails[i] + 0.2)
            x *= (1 + y * 0.92) ** 2.2 + 0.02
        pts.append((c + x, py))
    f.m.polygon(pts, fill=255)
    if i == 3:
        f.m.ellipse((c + f.px(0.16) - f.px(0.05), f.px(0.24) - f.px(0.05), c + f.px(0.16) + f.px(0.05), f.px(0.24) + f.px(0.05)), fill=255)
    f.l.polygon(ellipse(c - r * 0.35, head_y - r * 0.25, r * 0.22, r * 0.32, -0.5), fill=255)


def goo(f, rng, i):
    c = f.big / 2
    f.m.polygon(polar(c, c + f.px(0.03), f.px(0.27), rng, lobes=4, amp=0.22), fill=255)
    if i in (1, 3):
        a = rng.uniform(0, math.tau)
        bx, by = c + math.cos(a) * f.px(0.27), c + math.sin(a) * f.px(0.27)
        f.m.ellipse((bx - f.px(0.1), by - f.px(0.1), bx + f.px(0.1), by + f.px(0.1)), fill=255)
    f.mask = melt(f.mask, 2.5)
    f.m = ImageDraw.Draw(f.mask)
    f.l.polygon(ellipse(c - f.px(0.09), c - f.px(0.08), f.px(0.07), f.px(0.04), -0.6), fill=255)


def sparks(f, rng, i):
    c = f.big / 2
    if i < 3:
        length = f.px([0.78, 0.6, 0.45][i])
        width = f.px([0.09, 0.11, 0.13][i])
        top = c - length / 2
        pts = [(c, top)]
        for k in range(1, 21):
            t = k / 20
            pts.append((c + width / 2 * t ** 0.6, top + length * t * 0.92))
        pts.append((c, top + length))
        for k in range(20, 0, -1):
            t = k / 20
            pts.append((c - width / 2 * t ** 0.6, top + length * t * 0.92))
        f.m.polygon(pts, fill=255)
        f.l.ellipse((c - width * 0.3, top + length * 0.78, c + width * 0.3, top + length * 0.95), fill=255)
    else:
        for a in (0, math.pi / 2):
            pts = [(c, c - f.px(0.36)), (c + f.px(0.05), c), (c, c + f.px(0.36)), (c - f.px(0.05), c)]
            f.m.polygon(rot(pts, a, c, c), fill=255)
        f.m.ellipse((c - f.px(0.08), c - f.px(0.08), c + f.px(0.08), c + f.px(0.08)), fill=255)


def chips(f, rng, i):
    c = f.big / 2
    n = [4, 5, 5, 6][i]
    pts = []
    for k in range(n):
        a = k / n * math.tau + rng.uniform(-0.35, 0.35)
        r = f.px(rng.uniform(0.22, 0.36))
        pts.append((c + math.cos(a) * r, c + math.sin(a) * r * rng.uniform(0.6, 1.0)))
    f.m.polygon(pts, fill=255)
    # One facet in shadow: the chip catches light on one side only.
    k = rng.randrange(n)
    f.d.polygon([pts[k], pts[(k + 1) % n], (c, c)], fill=160)
    f.l.line([pts[(k + 2) % n], pts[(k + 3) % n]], fill=200, width=SS * 2)


def splinters(f, rng, i):
    c = f.big / 2
    length = f.px([0.84, 0.7, 0.78, 0.55][i])
    width = f.px([0.12, 0.16, 0.1, 0.2][i])
    angle = rng.uniform(-0.5, 0.5)
    top = c - length / 2
    left = jagged((c - width * 0.15, top), (c - width / 2, top + length), rng, 9, width * 0.12)
    right = jagged((c + width / 2, top + length), (c + width * 0.1, top + length * 0.05), rng, 9, width * 0.12)
    pts = left + right
    if i == 2:
        # A forked splinter.
        pts = left + [(c, top + length * 0.55), (c + width * 0.4, top - length * 0.02)] + right[3:]
    f.m.polygon(rot(pts, angle, c, c), fill=255)
    for k in range(3):
        x = c + (k - 1) * width * 0.25
        f.d.line(rot([(x, top + length * 0.1), (x + rng.uniform(-3, 3) * SS, top + length * 0.9)], angle, c, c), fill=200, width=SS)


def gravel(f, rng, i):
    c = f.big / 2
    r = f.px([0.3, 0.26, 0.22, 0.33][i])
    pts = polar(c, c, r, rng, lobes=5, amp=0.18)
    squash = rng.uniform(0.72, 0.9)
    pts = [(x, c + (y - c) * squash) for x, y in pts]
    f.m.polygon(pts, fill=255)
    if i == 2:
        f.m.polygon(polar(c + r * 1.05, c + r * 0.6, r * 0.45, rng, lobes=4, amp=0.2), fill=255)
    for _ in range(6):
        x, y = c + rng.uniform(-r, r) * 0.6, c + rng.uniform(-r, r) * 0.5
        s = SS * rng.uniform(0.8, 1.6)
        f.d.ellipse((x - s, y - s, x + s, y + s), fill=230)


def shards(f, rng, i):
    c = f.big / 2
    if i == 0:
        pts = [(c - f.px(0.08), c - f.px(0.38)), (c + f.px(0.2), c + f.px(0.3)), (c - f.px(0.18), c + f.px(0.22))]
    elif i == 1:
        pts = [(c - f.px(0.3), c - f.px(0.1)), (c + f.px(0.32), c - f.px(0.2)), (c + f.px(0.1), c + f.px(0.24))]
    elif i == 2:
        pts = [(c, c - f.px(0.4)), (c + f.px(0.07), c + f.px(0.36)), (c - f.px(0.06), c + f.px(0.3))]
    else:
        pts = [(c - f.px(0.25), c - f.px(0.25)), (c + f.px(0.15), c - f.px(0.3)), (c + f.px(0.28), c + f.px(0.15)), (c - f.px(0.05), c + f.px(0.3))]
    pts = rot(pts, rng.uniform(-0.6, 0.6), c, c)
    f.m.polygon(pts, fill=255)
    f.l.line([pts[0], pts[1]], fill=255, width=SS * 2)
    mid = ((pts[0][0] + pts[-1][0]) / 2, (pts[0][1] + pts[-1][1]) / 2)
    f.l.line([mid, pts[1]], fill=120, width=SS)


def fragments(f, rng, i):
    # A broken piece of a curved shell: a ring segment with jagged ends.
    c = f.big / 2
    outer = f.px([0.4, 0.36, 0.42, 0.32][i])
    thick = f.px([0.16, 0.2, 0.12, 0.18][i])
    span = [1.4, 1.1, 1.7, 0.9][i]
    start = rng.uniform(0, math.tau)
    cx, cy = c, c + outer * 0.45
    arc_out = [(cx + math.cos(start + span * t) * outer, cy + math.sin(start + span * t) * outer) for t in (k / 20 for k in range(21))]
    arc_in = [(cx + math.cos(start + span * t) * (outer - thick), cy + math.sin(start + span * t) * (outer - thick)) for t in (k / 20 for k in range(20, -1, -1))]
    end_cut = jagged(arc_out[-1], arc_in[0], rng, 5, thick * 0.25)
    start_cut = jagged(arc_in[-1], arc_out[0], rng, 5, thick * 0.25)
    pts = arc_out + end_cut[1:-1] + arc_in + start_cut[1:-1]
    # Recentre the piece in its frame.
    mx = sum(x for x, _ in pts) / len(pts)
    my = sum(y for _, y in pts) / len(pts)
    pts = [(x - mx + c, y - my + c) for x, y in pts]
    f.m.polygon(pts, fill=255)
    inner_edge = pts[len(arc_out) + len(end_cut) - 2: len(arc_out) + len(end_cut) - 2 + len(arc_in)]
    f.d.line(inner_edge, fill=210, width=SS * 3)
    f.l.line(pts[:len(arc_out)], fill=200, width=SS * 2)


def scales(f, rng, i):
    c = f.big / 2
    r = f.px([0.3, 0.26, 0.32, 0.22][i])
    pts = []
    for k in range(60):
        t = math.pi * k / 59
        pts.append((c + math.cos(t) * r, c - r * 0.25 + math.sin(t) * r * 1.15))
    pts.append((c, c - r * (0.55 if i != 3 else 0.3)))
    f.m.polygon(pts, fill=255)
    f.l.polygon(ellipse(c - r * 0.2, c + r * 0.2, r * 0.18, r * 0.5, 0.4), fill=230)
    f.d.line([(c, c - r * 0.4), (c, c + r * 0.85)], fill=110, width=SS * 2)


def feathers(f, rng, i):
    c = f.big / 2
    length = f.px([0.86, 0.6, 0.8, 0.7][i])
    width = f.px([0.2, 0.26, 0.16, 0.24][i])
    bend = f.px(rng.uniform(-0.08, 0.08))
    quill = bezier((c, c + length / 2), (c + bend * 2, c), (c + bend, c - length / 2), 30)
    left, right = [], []
    for k, (x, y) in enumerate(quill):
        t = k / (len(quill) - 1)
        w = width * (math.sin(math.pi * min(1.0, t * 1.15)) ** 0.7) * (0.25 if t < 0.18 else 1.0)
        left.append((x - w * 0.55, y))
        right.append((x + w * 0.45, y))
    f.m.polygon(left + right[::-1], fill=255)
    if i == 1:
        f.mask = f.mask.filter(ImageFilter.GaussianBlur(3 * SS))
        f.m = ImageDraw.Draw(f.mask)
    if i == 3:
        # A split in the vane.
        k = len(quill) // 2
        x, y = right[k]
        f.m.polygon([(x + SS * 2, y), (quill[k][0] + SS, quill[k][1] - SS * 4), (x + SS * 2, y - f.px(0.08))], fill=0)
    stroke(f.d, quill, SS * 3, SS * 1.2, fill=170)
    stroke(f.m, quill[:6], SS * 3, SS * 2.5)


def tufts(f, rng, i):
    c = f.big / 2
    root = (c + f.px(rng.uniform(-0.05, 0.05)), c + f.px(0.3))
    strands = [11, 14, 9, 16][i]
    spread = [1.0, 1.3, 0.7, 1.6][i]
    for k in range(strands):
        a = -math.pi / 2 + (k / (strands - 1) - 0.5) * spread + rng.uniform(-0.12, 0.12)
        ln = f.px(rng.uniform(0.38, 0.58))
        tip = (root[0] + math.cos(a) * ln, root[1] + math.sin(a) * ln)
        curl = f.px(rng.uniform(-0.12, 0.12))
        mid = ((root[0] + tip[0]) / 2 + curl, (root[1] + tip[1]) / 2)
        stroke(f.m, bezier(root, mid, tip, 16), SS * rng.uniform(4, 6), SS * 1.0)
    # The roots sit in shadow, the tips catch the light.
    f.d.ellipse((root[0] - f.px(0.1), root[1] - f.px(0.14), root[0] + f.px(0.1), root[1] + f.px(0.04)), fill=110)


def leaves(f, rng, i):
    c = f.big / 2
    length = f.px([0.78, 0.84, 0.66, 0.5][i])
    width = f.px([0.36, 0.2, 0.5, 0.42][i])
    top = c - length / 2
    left, right = [], []
    for k in range(41):
        t = k / 40
        w = width / 2 * math.sin(math.pi * t) ** (0.75 if i != 1 else 1.1)
        if i == 2:
            w *= 1 + 0.28 * math.sin(t * math.pi * 6) ** 2
        y = top + length * t
        left.append((c - w, y))
        right.append((c + w * 0.92, y))
    pts = left + right[::-1]
    angle = rng.uniform(-0.6, 0.6)
    f.m.polygon(rot(pts, angle, c, c), fill=255)
    # The stem, then the veins.
    stem = [(c, top + length), (c, top + length + f.px(0.08))]
    stroke(f.m, rot(stem, angle, c, c), SS * 2.5, SS * 2)
    stroke(f.d, rot([(c, top + length * 0.04), (c, top + length)], angle, c, c), SS * 1.2, SS * 2.5, fill=190)
    for k in range(1, 5):
        y = top + length * k / 5.5
        for side in (-1, 1):
            vein = [(c, y + length * 0.06), (c + side * width * 0.32, y)]
            stroke(f.d, rot(vein, angle, c, c), SS * 1.4, SS * 0.6, fill=130)


def scraps(f, rng, i):
    c = f.big / 2
    w = f.px([0.6, 0.5, 0.7, 0.44][i])
    h = f.px([0.4, 0.52, 0.3, 0.44][i])
    x0, y0, x1, y1 = c - w / 2, c - h / 2, c + w / 2, c + h / 2
    top = jagged((x0, y0), (x1, y0), rng, 8, h * 0.08) if i in (0, 2, 3) else [(x0, y0), (x1, y0)]
    right = jagged((x1, y0), (x1, y1), rng, 6, w * 0.08) if i in (1, 3) else [(x1, y0), (x1, y1)]
    bottom = jagged((x1, y1), (x0, y1), rng, 8, h * 0.08) if i in (0, 1) else [(x1, y1), (x0, y1)]
    left = [(x0, y1), (x0, y0)]
    pts = top + right[1:] + bottom[1:] + left[1:]
    angle = rng.uniform(-0.8, 0.8)
    f.m.polygon(rot(pts, angle, c, c), fill=255)
    # The corrugation shows through as faint ridges.
    for k in range(1, 6):
        x = x0 + w * k / 6
        f.d.line(rot([(x, y0 + SS * 2), (x, y1 - SS * 2)], angle, c, c), fill=80, width=SS * 2)
    if i == 2:
        f.d.polygon(rot([(c, y0), (x1, y0), (x1, y1), (c, y1)], angle, c, c), fill=90)


def embers(f, rng, i):
    c = f.big / 2
    length = f.px([0.7, 0.55, 0.8, 0.45][i])
    width = f.px([0.26, 0.3, 0.2, 0.32][i])
    lean = f.px(rng.uniform(-0.12, 0.12))
    base_y = c + length * 0.42
    axis = bezier((c, base_y), (c - lean, base_y - length * 0.5), (c + lean, base_y - length), 30)
    left, right = [], []
    for k, (x, y) in enumerate(axis):
        t = k / (len(axis) - 1)
        w = width / 2 * (math.sin(math.pi * (0.15 + 0.85 * t)) if t < 0.3 else (1 - t) ** 0.9 * 1.1)
        left.append((x - w, y))
        right.append((x + w, y))
    f.m.polygon(left + right[::-1], fill=255)
    f.l.ellipse((c - width * 0.2, base_y - length * 0.35, c + width * 0.2, base_y - length * 0.05), fill=180)


def pepper(f, rng, i):
    c = f.big / 2
    n = [5, 6, 4, 5][i]
    pts = []
    for k in range(n):
        a = k / n * math.tau + rng.uniform(-0.3, 0.3)
        r = f.px(rng.uniform(0.16, 0.3))
        pts.append((c + math.cos(a) * r, c + math.sin(a) * r * 0.55))
    pts = rot(pts, rng.uniform(0, math.pi), c, c)
    f.m.polygon(pts, fill=255)
    if i in (1, 3):
        # A seed stuck to the flake.
        sx, sy = c + f.px(0.04), c - f.px(0.02)
        f.l.polygon(ellipse(sx, sy, f.px(0.07), f.px(0.045), rng.uniform(0, math.pi)), fill=255)
    f.d.line([pts[0], pts[2]], fill=150, width=SS * 2)


def frost(f, rng, i):
    c = f.big / 2

    def crystal(cx, cy, length, width, angle):
        pts = [(cx, cy - length / 2), (cx + width / 2, cy - length * 0.3), (cx + width / 2, cy + length * 0.3),
               (cx, cy + length / 2), (cx - width / 2, cy + length * 0.3), (cx - width / 2, cy - length * 0.3)]
        pts = rot(pts, angle, cx, cy)
        f.m.polygon(pts, fill=255)
        f.l.line([pts[0], pts[3]], fill=230, width=SS * 2)
        f.l.line([pts[0], pts[1], pts[2]], fill=150, width=SS)

    if i == 0:
        crystal(c, c, f.px(0.8), f.px(0.2), 0.2)
    elif i == 1:
        crystal(c, c, f.px(0.6), f.px(0.24), -0.4)
    elif i == 2:
        crystal(c - f.px(0.06), c, f.px(0.62), f.px(0.16), 0.5)
        crystal(c + f.px(0.08), c + f.px(0.04), f.px(0.46), f.px(0.14), -0.6)
    else:
        for k in range(3):
            crystal(c, c, f.px(0.7), f.px(0.1), k * math.pi / 3)


def arcs(f, rng, i):
    c = f.big / 2
    top, bottom = f.px(0.08), f.px(0.92)
    steps = 9
    pts = [(c + f.px(rng.uniform(-0.1, 0.1)), top)]
    for k in range(1, steps):
        pts.append((c + f.px(rng.uniform(-0.22, 0.22)), top + (bottom - top) * k / steps))
    pts.append((c + f.px(rng.uniform(-0.1, 0.1)), bottom))
    stroke(f.m, pts, SS * 5, SS * 3)
    for _ in range([1, 2, 1, 3][i]):
        k = rng.randrange(2, steps - 2)
        x, y = pts[k]
        branch = [(x, y)]
        side = rng.choice((-1, 1))
        for s in range(1, 4):
            bx, by = branch[-1]
            branch.append((bx + side * f.px(rng.uniform(0.03, 0.08)), by + f.px(rng.uniform(0.05, 0.09))))
        stroke(f.m, branch, SS * 3, SS * 1.5)


def bubbles(f, rng, i):
    c = f.big / 2
    layout = [[(0, 0, 0.36)], [(-0.12, 0.06, 0.26), (0.17, -0.12, 0.18)],
              [(-0.14, 0.1, 0.17), (0.12, 0.12, 0.14), (0.0, -0.16, 0.15)], [(0.02, 0.05, 0.3), (-0.26, -0.24, 0.09)]][i]
    for dx, dy, r in layout:
        x, y, rr = c + f.px(dx), c + f.px(dy), f.px(r)
        f.m.ellipse((x - rr, y - rr, x + rr, y + rr), fill=70)
        f.m.ellipse((x - rr, y - rr, x + rr, y + rr), outline=255, width=int(max(SS * 2, rr * 0.14)))
        f.l.arc((x - rr * 0.7, y - rr * 0.7, x + rr * 0.7, y + rr * 0.7), 200, 260, fill=255, width=int(max(SS * 2, rr * 0.16)))


def sizzle(f, rng, i):
    drops(f, rng, i)
    c = f.big / 2
    for _ in range(3):
        x, y = c + f.px(rng.uniform(-0.08, 0.08)), f.px(rng.uniform(0.55, 0.72))
        r = f.px(rng.uniform(0.025, 0.045))
        f.l.ellipse((x - r, y - r, x + r, y + r), outline=255, width=SS * 2)


# name: (drawer, frame size, style)
FAMILIES = {
    'drops': (drops, 64, {'relief': 0.25, 'depth': 0.08}),
    'goo': (goo, 128, {'relief': 0.3, 'depth': 0.1}),
    'sparks': (sparks, 64, {'glow': True, 'halo': 0.08}),
    'chips': (chips, 64, {'relief': 0.15, 'depth': 0.05}),
    'splinters': (splinters, 64, {'relief': 0.12, 'depth': 0.04}),
    'gravel': (gravel, 64, {'relief': 0.35, 'depth': 0.09}),
    'shards': (shards, 64, {'relief': 0.1, 'depth': 0.04, 'opacity': 0.55, 'edge_alpha': True}),
    'fragments': (fragments, 64, {'relief': 0.2, 'depth': 0.04}),
    'scales': (scales, 64, {'relief': 0.3, 'depth': 0.07}),
    'feathers': (feathers, 128, {'relief': 0.08, 'depth': 0.03, 'dark_strength': 0.3}),
    'tufts': (tufts, 128, {'relief': 0.12, 'depth': 0.03, 'dark_strength': 0.35}),
    'leaves': (leaves, 128, {'relief': 0.12, 'depth': 0.04, 'dark_strength': 0.35}),
    'scraps': (scraps, 128, {'relief': 0.08, 'depth': 0.03, 'dark_strength': 0.3}),
    'embers': (embers, 64, {'glow': True, 'halo': 0.1}),
    'pepper': (pepper, 64, {'relief': 0.2, 'depth': 0.05}),
    'frost': (frost, 64, {'relief': 0.1, 'depth': 0.04, 'opacity': 0.6, 'edge_alpha': True}),
    'arcs': (arcs, 128, {'glow': True, 'halo': 0.05, 'halo_strength': 0.8}),
    'bubbles': (bubbles, 64, {'relief': 0.05, 'depth': 0.03, 'base': 1.0}),
    'sizzle': (sizzle, 64, {'relief': 0.25, 'depth': 0.08}),
}
SOFT = {
    # name: (frame size, puffs, spread, density, shade)
    'dust': (128, 16, 0.2, 1.3, 0.25),
    'smoke': (128, 20, 0.22, 1.6, 0.35),
}


def atlas(name, seed):
    rng = random.Random(seed)
    nrng = np.random.default_rng(seed)
    if name in SOFT:
        size, puffs, spread, density, shade = SOFT[name]
        frames = [soft_frame(size, nrng, puffs + k, spread, density, shade) for k in range(4)]
    else:
        drawer, size, style = FAMILIES[name]
        frames = []
        for k in range(4):
            f = Frame(size)
            drawer(f, rng, k)
            frames.append(compose(f, style))
    sheet = Image.new('RGBA', (size * 2, size * 2), (255, 255, 255, 0))
    for k, frame in enumerate(frames):
        sheet.paste(frame, ((k % 2) * size, (k // 2) * size))
    sheet.save(f'{OUT}/fx_{name}.png')


names = list(FAMILIES) + list(SOFT)
for index, name in enumerate(names):
    if not ONLY or name in ONLY:
        atlas(name, 100 + index * 7)
print('drawn', len(names) if not ONLY else len(ONLY))
