#!/usr/bin/env python3 """Rasterise the design's icon paths into tintable bitmap resources. The sunrise and weather icons in design/*.svg are traced outlines whose finest features are two or three design pixels across. Redrawing them from circles and lines works at 454 px and falls apart at 280 px, where those features land under one device pixel. Rendering the real paths once per screen size and blitting them with drawBitmap2(:tintColor) keeps them exact everywhere. python3 tools/gen_icons.py Writes resources-icons-/drawables/{sunrise,sunset,weather}.png plus the matching drawables.xml. White pixels, alpha coverage; the theme's accent colour is applied at draw time via :tintColor. MIP devices have no alpha blending, so for those widths the coverage mask is thresholded to hard edges rather than left antialiased -- a soft edge there would be quantised to something muddier than a clean one. """ import os import re HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) SRC = os.path.join(ROOT, "design", "watchface-01-ember.svg") try: from PIL import Image except ImportError: raise SystemExit("this generator needs Pillow: python3 -m pip install pillow") # The three icons the comps actually contain, and the design-space centre each # one is drawn around. A common 32x32 design box keeps blitting trivial. BOX = 32.0 ICONS = { "sunrise": ("sunrise-icon", 156.32, 89.5, False), "sunset": ("sunrise-icon", 156.32, 89.5, True), # same art, chevron flipped "weather": ("weather-icon", 343.55, 89.5, False), } # screen width -> whether the panel can blend alpha (MIP cannot) WIDTHS = {240: False, 260: False, 280: False, 390: True, 416: True, 454: True} SS = 8 # supersampling factor def subpaths(svg, pid): d = re.search(r'= 3: out.append(pts) return out def flip_chevron(paths): """Mirror the horizon bar so its notch points up -- our sunset variant.""" out = [] for p in paths: ys = [q[1] for q in p] if min(ys) > 96.0: # the bar is the lowest subpath mid = (min(ys) + max(ys)) / 2.0 p = [(x, 2 * mid - y) for x, y in p] out.append(p) return out def scanline_evenodd(paths, size, ox, oy, scale): """Even-odd fill at supersampled resolution, returned as a coverage mask.""" n = size * SS cov = [bytearray(n) for _ in range(n)] edges = [] for p in paths: for i in range(len(p)): x0, y0 = p[i] x1, y1 = p[(i + 1) % len(p)] X0 = (x0 - ox) * scale * SS Y0 = (y0 - oy) * scale * SS X1 = (x1 - ox) * scale * SS Y1 = (y1 - oy) * scale * SS if Y0 != Y1: edges.append((Y0, Y1, X0, X1)) for row in range(n): yc = row + 0.5 xs = [] for Y0, Y1, X0, X1 in edges: if (Y0 <= yc < Y1) or (Y1 <= yc < Y0): xs.append(X0 + (yc - Y0) * (X1 - X0) / (Y1 - Y0)) if not xs: continue xs.sort() line = cov[row] for i in range(0, len(xs) - 1, 2): a = max(0, int(round(xs[i]))) b = min(n, int(round(xs[i + 1]))) for x in range(a, b): line[x] = 255 return cov def render(paths, size, cx, cy, blend): scale = size / BOX ox, oy = cx - BOX / 2, cy - BOX / 2 cov = scanline_evenodd(paths, size, ox, oy, scale) n = size * SS big = Image.frombytes("L", (n, n), b"".join(bytes(r) for r in cov)) small = big.resize((size, size), Image.LANCZOS) if not blend: small = small.point(lambda v: 255 if v >= 110 else 0) white = Image.new("RGB", (size, size), (255, 255, 255)) img = white.convert("RGBA") img.putalpha(small) return img # Launcher icon sizes the target devices ask for. LAUNCHER = [40, 56, 60, 65, 70] def launcher(size): """A miniature of the face: the gradient ring plus the date band.""" from PIL import ImageDraw ss = 8 n = size * ss img = Image.new("RGBA", (n, n), (0, 0, 0, 0)) d = ImageDraw.Draw(img) d.ellipse([0, 0, n - 1, n - 1], fill=(0, 0, 0, 255)) svg = open(SRC).read() top = re.findall(r'fill="(#[0-9A-Fa-f]{6})"', re.search(r'(.*?)', svg, re.S).group(1)) bot = re.findall(r'fill="(#[0-9A-Fa-f]{6})"', re.search(r'(.*?)', svg, re.S).group(1)) ro, ri = n * 0.495, n * 0.375 box_o = [n / 2 - ro, n / 2 - ro, n / 2 + ro, n / 2 + ro] # sweep the whole rim, mirroring the comp's two ramps into four quadrants for q, ramp in ((0, top), (1, top), (2, bot), (3, bot)): for i, c in enumerate(ramp): col = tuple(int(c[1 + 2 * j:3 + 2 * j], 16) for j in range(3)) + (255,) span = 90.0 / len(ramp) if q == 0: a0 = -90 + i * span elif q == 1: a0 = -90 - (i + 1) * span elif q == 2: a0 = 90 - (i + 1) * span else: a0 = 90 + i * span d.pieslice(box_o, a0, a0 + span, fill=col) d.ellipse([n / 2 - ri, n / 2 - ri, n / 2 + ri, n / 2 + ri], fill=(0, 0, 0, 255)) band = tuple(int("FEAA00"[2 * j:2 * j + 2], 16) for j in range(3)) + (255,) d.rectangle([0, n * 0.44, n, n * 0.60], fill=band) mask = Image.new("L", (n, n), 0) ImageDraw.Draw(mask).ellipse([0, 0, n - 1, n - 1], fill=255) img.putalpha(mask) return img.resize((size, size), Image.LANCZOS) def main(): svg = open(SRC).read() for size in LAUNCHER: outdir = os.path.join(ROOT, "resources-launcher-%d" % size, "drawables") os.makedirs(outdir, exist_ok=True) launcher(size).save(os.path.join(outdir, "launcher_icon.png")) with open(os.path.join(outdir, "drawables.xml"), "w") as f: f.write('\n' ' \n' '\n') print("wrote %s (%dx%d)" % (outdir, size, size)) cache = {} for width, blend in sorted(WIDTHS.items()): size = int(round(BOX * width / 500.0)) outdir = os.path.join(ROOT, "resources-icons-%d" % width, "drawables") os.makedirs(outdir, exist_ok=True) names = [] for name, (pid, cx, cy, flip) in ICONS.items(): key = (pid, flip) if key not in cache: p = subpaths(svg, pid) cache[key] = flip_chevron(p) if flip else p img = render(cache[key], size, cx, cy, blend) img.save(os.path.join(outdir, "%s.png" % name)) names.append(name) with open(os.path.join(outdir, "drawables.xml"), "w") as f: f.write("\n") for n in names: # packingFormat="png" keeps the alpha channel instead of # collapsing the image to a palette; drawBitmap2(:tintColor) # refuses a palettised source. f.write(' \n' % (n.capitalize(), n)) f.write("\n") print("wrote %s (%dx%d, %s edges)" % (outdir, size, size, "soft" if blend else "hard")) if __name__ == "__main__": main()