123 lines
4.7 KiB
Python
123 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Generates Snaggle's app icon set.
|
|
|
|
Draws an original mark - a rounded square with a capture viewfinder - at high
|
|
resolution, then writes every size macOS asks for into the asset catalogue.
|
|
Run it again after editing the colours or shapes below:
|
|
|
|
python3 scripts/make_appicon.py
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from PIL import Image, ImageDraw, ImageFilter
|
|
|
|
SUPERSAMPLE = 4
|
|
BASE = 1024
|
|
OUTPUT = Path(__file__).resolve().parent.parent / "Snaggle" / "Resources" / "Assets.xcassets" / "AppIcon.appiconset"
|
|
|
|
# Deep to bright green, reading as one surface rather than a flat fill.
|
|
TOP_COLOR = (46, 173, 92)
|
|
BOTTOM_COLOR = (18, 106, 62)
|
|
MARK_COLOR = (255, 255, 255, 242)
|
|
|
|
|
|
def vertical_gradient(size, top, bottom):
|
|
gradient = Image.new("RGB", (1, size), top)
|
|
for y in range(size):
|
|
blend = y / max(1, size - 1)
|
|
gradient.putpixel((0, y), tuple(
|
|
round(top[channel] + (bottom[channel] - top[channel]) * blend) for channel in range(3)
|
|
))
|
|
return gradient.resize((size, size))
|
|
|
|
|
|
def draw_icon(size):
|
|
scale = size / BASE
|
|
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
|
|
# macOS leaves a margin around the rounded square and uses a large corner radius.
|
|
margin = round(100 * scale)
|
|
side = size - margin * 2
|
|
radius = round(185 * scale)
|
|
|
|
mask = Image.new("L", (size, size), 0)
|
|
ImageDraw.Draw(mask).rounded_rectangle(
|
|
[margin, margin, size - margin, size - margin], radius=radius, fill=255)
|
|
|
|
body = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
body.paste(vertical_gradient(size, TOP_COLOR, BOTTOM_COLOR), (0, 0))
|
|
body.putalpha(mask)
|
|
|
|
# Soft highlight across the top, blurred so there is no hard glossy edge.
|
|
highlight = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
ImageDraw.Draw(highlight).ellipse(
|
|
[margin - side * 0.35, margin - side * 0.95, size - margin + side * 0.35, margin + side * 0.40],
|
|
fill=(255, 255, 255, 34))
|
|
highlight = highlight.filter(ImageFilter.GaussianBlur(round(60 * scale)))
|
|
highlight.putalpha(Image.composite(highlight.split()[3], Image.new("L", (size, size), 0), mask))
|
|
body = Image.alpha_composite(body, highlight)
|
|
|
|
canvas = Image.alpha_composite(canvas, body)
|
|
|
|
# The mark: four viewfinder corners around an aperture ring.
|
|
draw = ImageDraw.Draw(canvas)
|
|
stroke = max(1, round(46 * scale))
|
|
centre = size / 2
|
|
reach = side * 0.30 # how far the corners sit from the centre
|
|
arm = side * 0.13 # length of each corner arm
|
|
|
|
def round_cap(x, y):
|
|
draw.ellipse([x - stroke / 2, y - stroke / 2, x + stroke / 2, y + stroke / 2], fill=MARK_COLOR)
|
|
|
|
for x_sign in (-1, 1):
|
|
for y_sign in (-1, 1):
|
|
x = centre + x_sign * reach
|
|
y = centre + y_sign * reach
|
|
# One polyline per corner keeps the elbow smooth instead of notched.
|
|
start = (x - x_sign * arm, y)
|
|
elbow = (x, y)
|
|
end = (x, y - y_sign * arm)
|
|
draw.line([start, elbow, end], fill=MARK_COLOR, width=stroke, joint="curve")
|
|
for point in (start, elbow, end):
|
|
round_cap(*point)
|
|
|
|
ring = side * 0.135
|
|
draw.ellipse([centre - ring, centre - ring, centre + ring, centre + ring],
|
|
outline=MARK_COLOR, width=stroke)
|
|
|
|
# Shadow under the rounded square, the way system icons sit on a surface.
|
|
shadow = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
ImageDraw.Draw(shadow).rounded_rectangle(
|
|
[margin, margin + round(18 * scale), size - margin, size - margin + round(18 * scale)],
|
|
radius=radius, fill=(0, 0, 0, 70))
|
|
shadow = shadow.filter(ImageFilter.GaussianBlur(round(18 * scale)))
|
|
return Image.alpha_composite(shadow, canvas)
|
|
|
|
|
|
def main():
|
|
OUTPUT.mkdir(parents=True, exist_ok=True)
|
|
master = draw_icon(BASE * SUPERSAMPLE).resize((BASE, BASE), Image.LANCZOS)
|
|
|
|
# (point size, scale) pairs macOS expects for a Mac app icon.
|
|
entries = []
|
|
for point, scales in [(16, [1, 2]), (32, [1, 2]), (128, [1, 2]), (256, [1, 2]), (512, [1, 2])]:
|
|
for factor in scales:
|
|
pixels = point * factor
|
|
name = f"icon_{point}x{point}{'@2x' if factor == 2 else ''}.png"
|
|
master.resize((pixels, pixels), Image.LANCZOS).save(OUTPUT / name)
|
|
entries.append(f""" {{
|
|
"filename" : "{name}",
|
|
"idiom" : "mac",
|
|
"scale" : "{factor}x",
|
|
"size" : "{point}x{point}"
|
|
}}""")
|
|
|
|
contents = "{\n \"images\" : [\n" + ",\n".join(entries) + \
|
|
"\n ],\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\n"
|
|
(OUTPUT / "Contents.json").write_text(contents)
|
|
print(f"Wrote {len(entries)} icon sizes to {OUTPUT}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|