"""Draw the Nuvolari launcher icon: a stylised cloud. Produces two 1024x1024 sources, which `flutter_launcher_icons` then fans out to every Android density, the Android adaptive icon and the iOS set: app/assets/icon/icon.png white cloud on the app blue, full bleed app/assets/icon/icon_foreground.png the cloud alone, transparent The foreground is drawn smaller on purpose. An Android adaptive icon layer is 108dp of which only the central 72dp is guaranteed to survive the launcher's mask, so a cloud drawn edge to edge would lose its outer puffs to a circle. This file owns that framing: flutter_launcher_icons is configured with `adaptive_icon_foreground_inset: 0` so it does not shrink the layer a second time on top of what is drawn here. Shapes are rendered from signed distance fields rather than by supersampling: a cloud is a union of circles and one rounded box, the distance to that union is exact, and turning distance into coverage gives clean antialiasing at one sample per pixel. That keeps this to the standard library — no Pillow, no cairo, nothing for anyone to install. Usage: python tool/generate_icon.py """ from __future__ import annotations import math import pathlib import struct import zlib SIZE = 1024 OUT_DIR = pathlib.Path(__file__).resolve().parent.parent / "app" / "assets" / "icon" # The app's seed colour, so the icon and the interface are recognisably the same # product. Keep these in step with ColorScheme.fromSeed in lib/main.dart. BACKGROUND = (0x1F, 0x6F, 0xB2) CLOUD = (0xFF, 0xFF, 0xFF) # How much of the canvas width the cloud spans. # # The full icon can run closer to the edges: iOS and the legacy Android icon are # square and unmasked. FULL_WIDTH = 0.72 # The adaptive layer is measured against the 72dp the mask always keeps, not # against the 108dp canvas, so the two icons read at the same size on a home # screen. 0.72 of that safe zone leaves the widest points of the cloud at 0.82 # of the circle's radius — clear of a circular mask, and of the rounder masks # some launchers use. SAFE_ZONE = 72 / 108 ADAPTIVE_WIDTH = 0.72 * SAFE_ZONE # --- the cloud --------------------------------------------------------------- # # Defined in its own arbitrary coordinate space. The bounding box is computed # from these numbers rather than written alongside them, so the shape can be # tuned without the framing silently going wrong — which is exactly what # happened when the two were maintained separately. # (centre x, centre y, half width, half height, corner radius) BODY = (0.500, 0.560, 0.255, 0.075, 0.075) # (centre x, centre y, radius) # # The trailing puff rests on the base line: its lowest point is exactly the # bottom of the body, so the outline runs from the arc into the flat base # without turning back on itself. Left higher, the union re-enters and leaves a # visible notch at the bottom left. PUFFS = ( (0.415, 0.470, 0.140), # main puff, left of centre and largest (0.585, 0.495, 0.110), # shoulder puff, higher and smaller (0.290, BODY[1] + BODY[3] - 0.088, 0.088), # trailing puff, on the base ) def cloud_bounds() -> tuple[float, float, float, float]: """Axis-aligned bounds of the whole cloud: (min x, min y, max x, max y).""" cx, cy, hw, hh, _ = BODY min_x, min_y = cx - hw, cy - hh max_x, max_y = cx + hw, cy + hh for px, py, r in PUFFS: min_x = min(min_x, px - r) min_y = min(min_y, py - r) max_x = max(max_x, px + r) max_y = max(max_y, py + r) return min_x, min_y, max_x, max_y # --- signed distance fields -------------------------------------------------- def circle_sdf(x: float, y: float, cx: float, cy: float, r: float) -> float: return math.hypot(x - cx, y - cy) - r def rounded_box_sdf( x: float, y: float, cx: float, cy: float, half_w: float, half_h: float, radius: float, ) -> float: dx = abs(x - cx) - (half_w - radius) dy = abs(y - cy) - (half_h - radius) outside = math.hypot(max(dx, 0.0), max(dy, 0.0)) inside = min(max(dx, dy), 0.0) return outside + inside - radius def cloud_sdf(x: float, y: float) -> float: """Distance to the cloud: the union of the body and the puffs. The asymmetry is deliberate — a cloud built from equal circles reads as a flower. """ distance = rounded_box_sdf(x, y, *BODY) for puff in PUFFS: distance = min(distance, circle_sdf(x, y, *puff)) return distance # --- rendering --------------------------------------------------------------- def coverage(distance_px: float) -> float: """Turns a signed distance in pixels into pixel coverage. A half-pixel band across the boundary is all the antialiasing a shape this smooth needs. """ return min(max(0.5 - distance_px, 0.0), 1.0) def render(size: int, width_fraction: float, opaque_background: bool) -> list[bytes]: """Renders the icon, returning one RGBA bytes row per scanline. [width_fraction] is how much of the canvas width the cloud spans. The cloud is centred on its own bounding box, so changing the shape above cannot leave it drifting off-centre. """ min_x, min_y, max_x, max_y = cloud_bounds() cloud_width = max_x - min_x cloud_cx = (min_x + max_x) / 2 cloud_cy = (min_y + max_y) / 2 # Canvas pixel -> cloud space. pixels_per_unit = size * width_fraction / cloud_width rows: list[bytes] = [] for py in range(size): row = bytearray() sy = (py + 0.5 - size / 2) / pixels_per_unit + cloud_cy for px in range(size): sx = (px + 0.5 - size / 2) / pixels_per_unit + cloud_cx # Distance in cloud units, converted to pixels so the antialiasing # band stays one pixel wide at any output size. alpha = coverage(cloud_sdf(sx, sy) * pixels_per_unit) if opaque_background: r = round(BACKGROUND[0] + (CLOUD[0] - BACKGROUND[0]) * alpha) g = round(BACKGROUND[1] + (CLOUD[1] - BACKGROUND[1]) * alpha) b = round(BACKGROUND[2] + (CLOUD[2] - BACKGROUND[2]) * alpha) row += bytes((r, g, b, 255)) else: row += bytes((CLOUD[0], CLOUD[1], CLOUD[2], round(alpha * 255))) rows.append(bytes(row)) return rows def write_png(path: pathlib.Path, size: int, rows: list[bytes]) -> None: def chunk(kind: bytes, data: bytes) -> bytes: return ( struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) ) raw = b"".join(b"\x00" + row for row in rows) # filter type 0 per scanline png = ( b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"") ) path.write_bytes(png) def main() -> int: OUT_DIR.mkdir(parents=True, exist_ok=True) full = OUT_DIR / "icon.png" write_png(full, SIZE, render(SIZE, FULL_WIDTH, opaque_background=True)) print(f"{full.name}: {full.stat().st_size / 1024:.0f} KB") foreground = OUT_DIR / "icon_foreground.png" write_png( foreground, SIZE, render(SIZE, ADAPTIVE_WIDTH, opaque_background=False), ) print(f"{foreground.name}: {foreground.stat().st_size / 1024:.0f} KB") print( "\nbackground #%02X%02X%02X cloud #%02X%02X%02X" % (*BACKGROUND, *CLOUD) ) print("now run: flutter pub run flutter_launcher_icons") return 0 if __name__ == "__main__": raise SystemExit(main())