| """Génère les icônes PWA (PNG) sans dépendance externe. |
| |
| Rendu par supersampling 4x : dégradé de fond, bulle de discussion cyan et |
| éclair orange — la signature graphique reprise de l'affiche Motocultor. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import struct |
| import zlib |
| from pathlib import Path |
|
|
| OUT = Path(__file__).resolve().parent.parent / "static" / "icons" |
| SS = 4 |
|
|
| |
| NIGHT_TOP = (13, 40, 49) |
| NIGHT_BOTTOM = (6, 20, 25) |
| CYAN = (72, 201, 214) |
| BUBBLE_FILL = (15, 58, 69) |
| EMBER = (238, 139, 61) |
| LIGHTNING = (247, 221, 84) |
|
|
|
|
| def lerp(a, b, t): |
| return tuple(round(x + (y - x) * t) for x, y in zip(a, b)) |
|
|
|
|
| def rounded_rect_sdf(px, py, x0, y0, x1, y1, r): |
| """Distance signée à un rectangle arrondi (négative à l'intérieur).""" |
| cx = max(x0 + r, min(px, x1 - r)) |
| cy = max(y0 + r, min(py, y1 - r)) |
| dx, dy = px - cx, py - cy |
| return (dx * dx + dy * dy) ** 0.5 - r |
|
|
|
|
| def point_in_poly(px, py, poly): |
| inside = False |
| n = len(poly) |
| for i in range(n): |
| x0, y0 = poly[i] |
| x1, y1 = poly[(i + 1) % n] |
| if (y0 > py) != (y1 > py): |
| xin = x0 + (py - y0) * (x1 - x0) / (y1 - y0) |
| if px < xin: |
| inside = not inside |
| return inside |
|
|
|
|
| def over(dst, src, alpha): |
| return tuple(round(s * alpha + d * (1 - alpha)) for s, d in zip(src, dst)) |
|
|
|
|
| def render(size: int, maskable: bool = False) -> bytes: |
| """Renvoie les octets RGB (size x size).""" |
| big = size * SS |
| |
| pad = 0.28 if maskable else 0.16 |
| radius = big * (0.5 if maskable else 0.22) |
|
|
| |
| bx0, by0 = big * (pad + 0.02), big * (pad + 0.06) |
| bx1, by1 = big * (1 - pad - 0.02), big * (1 - pad - 0.16) |
| br = (bx1 - bx0) * 0.24 |
| stroke = max(1.0, big * 0.018) |
|
|
| |
| tail = [ |
| (bx0 + (bx1 - bx0) * 0.22, by1 - stroke), |
| (bx0 + (bx1 - bx0) * 0.44, by1 - stroke), |
| (bx0 + (bx1 - bx0) * 0.26, by1 + (by1 - by0) * 0.26), |
| ] |
|
|
| |
| cx, cy = (bx0 + bx1) / 2, (by0 + by1) / 2 |
| w, h = (bx1 - bx0) * 0.42, (by1 - by0) * 0.56 |
| bolt = [ |
| (cx + w * 0.16, cy - h * 0.50), |
| (cx - w * 0.50, cy + h * 0.10), |
| (cx - w * 0.08, cy + h * 0.10), |
| (cx - w * 0.20, cy + h * 0.50), |
| (cx + w * 0.50, cy - h * 0.14), |
| (cx + w * 0.04, cy - h * 0.14), |
| ] |
|
|
| rows = [] |
| for y in range(size): |
| row = bytearray() |
| for x in range(size): |
| racc = gacc = bacc = 0 |
| for sy in range(SS): |
| for sx in range(SS): |
| px = x * SS + sx + 0.5 |
| py = y * SS + sy + 0.5 |
|
|
| |
| col = lerp(NIGHT_TOP, NIGHT_BOTTOM, py / big) |
| d_bg = rounded_rect_sdf(px, py, 0, 0, big, big, radius) |
| bg_a = max(0.0, min(1.0, 0.5 - d_bg)) |
| col = over((0, 0, 0), col, bg_a) |
|
|
| |
| gy = max(0.0, (py / big - 0.55) / 0.45) |
| gx = 1.0 - abs(px / big - 0.5) * 1.6 |
| glow = max(0.0, gy * gx) * 0.30 * bg_a |
| if glow > 0: |
| col = over(col, EMBER, glow) |
|
|
| |
| d = rounded_rect_sdf(px, py, bx0, by0, bx1, by1, br) |
| in_tail = point_in_poly(px, py, tail) |
| fill_a = max(0.0, min(1.0, 0.5 - d)) |
| if in_tail: |
| fill_a = 1.0 |
| if fill_a > 0: |
| col = over(col, BUBBLE_FILL, fill_a) |
| edge = abs(d) - stroke / 2 |
| edge_a = max(0.0, min(1.0, 0.5 - edge)) |
| if in_tail: |
| edge_a = max(edge_a, 0.0) |
| if edge_a > 0: |
| col = over(col, CYAN, edge_a) |
|
|
| |
| if point_in_poly(px, py, bolt): |
| col = LIGHTNING |
| elif point_in_poly(px + stroke * 0.6, py + stroke * 0.6, bolt): |
| col = EMBER |
|
|
| racc += col[0] |
| gacc += col[1] |
| bacc += col[2] |
|
|
| n = SS * SS |
| row += bytes((racc // n, gacc // n, bacc // n)) |
| rows.append(bytes(row)) |
| return b"".join(b"\x00" + r for r in rows) |
|
|
|
|
| def write_png(path: Path, size: int, raw: bytes) -> None: |
| def chunk(tag: bytes, data: bytes) -> bytes: |
| body = tag + data |
| return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body)) |
|
|
| header = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) |
| png = ( |
| b"\x89PNG\r\n\x1a\n" |
| + chunk(b"IHDR", header) |
| + chunk(b"IDAT", zlib.compress(raw, 9)) |
| + chunk(b"IEND", b"") |
| ) |
| path.write_bytes(png) |
| print(f" {path.name} ({len(png) / 1024:.1f} Ko)") |
|
|
|
|
| def main() -> None: |
| OUT.mkdir(parents=True, exist_ok=True) |
| print("Génération des icônes :") |
| for size in (192, 512): |
| write_png(OUT / f"icon-{size}.png", size, render(size)) |
| for size in (192, 512): |
| write_png(OUT / f"maskable-{size}.png", size, render(size, maskable=True)) |
| write_png(OUT / "favicon-64.png", 64, render(64)) |
| write_png(OUT / "apple-touch-icon.png", 180, render(180, maskable=True)) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|