mirror of
https://github.com/SpeedBreakerProject/speedbreaker.git
synced 2026-10-17 19:01:23 +02:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
92 lines
4.3 KiB
Python
92 lines
4.3 KiB
Python
#!/usr/bin/env python3
|
|
# SpeedBreaker. GPL-3.0-or-later (see COPYING).
|
|
#
|
|
# make_icon.py: SpeedBreaker's iOS app icon (needs Pillow), in
|
|
# SpeedBreaker/Assets.xcassets/AppIcon.appiconset/AppIcon.png 1024x1024
|
|
# SpeedBreaker/Assets.xcassets/AppIcon.appiconset/Contents.json
|
|
# next to this script: one size, from which Xcode makes the rest. iOS wants
|
|
# a full-bleed, opaque square and rounds the corners itself, so this is
|
|
# scripts/macos/make_icon.py's drawing without its rounded mask and with
|
|
# heavier strokes (K, below), cut to the macOS icon's 824-pixel square and
|
|
# scaled to fill the 1024, so the dial
|
|
# sits in the iOS tile as it sits in the macOS one. Drawn again rather than
|
|
# cut from scripts/macos/AppIcon.png, whose corners are transparent and
|
|
# whose square would have to be scaled up. The catalog is kept in the repo,
|
|
# so building the app needs no Pillow. A change to the drawing goes in both
|
|
# scripts.
|
|
import json, math, os
|
|
from PIL import Image, ImageDraw, ImageFilter
|
|
|
|
S = 1024
|
|
SS = 4 # supersampling
|
|
W = S * SS
|
|
CATALOG = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'SpeedBreaker', 'Assets.xcassets')
|
|
AMBER = (255, 168, 31) # the settings menu's accent (ui.cpp kAccent)
|
|
# Strokes 1.5x the macOS icon's: its hairline ring and ticks thin out at the
|
|
# 60-180 px an iPhone or iPad shows (compared at those sizes, 2026-10-02).
|
|
K = 1.5
|
|
RED = (232, 64, 40)
|
|
|
|
# The drawing, as on macOS: the same canvas and geometry, with the
|
|
# background over all of it instead of only the rounded square.
|
|
inset = 100 * SS # where macOS's 824-pixel square starts
|
|
# Background: a vertical charcoal gradient.
|
|
img = Image.new('RGBA', (W, W))
|
|
d = ImageDraw.Draw(img)
|
|
for y in range(W):
|
|
t = y / W
|
|
c = (int(38 - 22 * t), int(40 - 23 * t), int(46 - 26 * t), 255)
|
|
d.line([(0, y), (W, y)], fill=c)
|
|
|
|
cx, cy = W // 2, int(W * 0.535)
|
|
r = int(W * 0.30)
|
|
start, end = 135, 405 # the dial: 270 degrees, open at the bottom
|
|
# Outer ring and the arc.
|
|
ring = (18 + 6 * (K - 1)) * SS
|
|
d.arc([cx - r - ring, cy - r - ring, cx + r + ring, cy + r + ring], start, end, fill=(70, 72, 80, 255), width=int(6 * K * SS))
|
|
d.arc([cx - r, cy - r, cx + r, cy + r], start, end - 60, fill=AMBER + (255,), width=int(26 * K * SS))
|
|
d.arc([cx - r, cy - r, cx + r, cy + r], end - 60, end, fill=RED + (255,), width=int(26 * K * SS))
|
|
# Ticks.
|
|
for i in range(11):
|
|
a = math.radians(start + i * 27)
|
|
inner = 44 + 13 * (K - 1) # clear of the wider arc
|
|
r0, r1 = r - inner * SS, r - (inner + (26 if i % 2 == 0 else 14) * (1 + 0.25 * (K - 1))) * SS
|
|
d.line([(cx + r0 * math.cos(a), cy + r0 * math.sin(a)), (cx + r1 * math.cos(a), cy + r1 * math.sin(a))],
|
|
fill=(235, 235, 240, 255), width=int((10 if i % 2 == 0 else 6) * K * SS))
|
|
# The needle, past the redline, with a soft glow.
|
|
a = math.radians(end - 22)
|
|
tip = (cx + (r - 30 * SS) * math.cos(a), cy + (r - 30 * SS) * math.sin(a))
|
|
perp = a + math.pi / 2
|
|
base_w = 22 * K * SS
|
|
p1 = (cx + base_w * math.cos(perp), cy + base_w * math.sin(perp))
|
|
p2 = (cx - base_w * math.cos(perp), cy - base_w * math.sin(perp))
|
|
glow = Image.new('RGBA', (W, W), (0, 0, 0, 0))
|
|
ImageDraw.Draw(glow).polygon([p1, tip, p2], fill=AMBER + (200,))
|
|
glow = glow.filter(ImageFilter.GaussianBlur(14 * SS))
|
|
img = Image.alpha_composite(img, glow)
|
|
d = ImageDraw.Draw(img)
|
|
d.polygon([p1, tip, p2], fill=(255, 196, 92, 255))
|
|
hub = int(40 * (1 + 0.15 * (K - 1)) * SS)
|
|
d.ellipse([cx - hub, cy - hub, cx + hub, cy + hub], fill=(28, 29, 34, 255), outline=AMBER + (255,), width=int(8 * K * SS))
|
|
|
|
# The macOS square, cut from the supersampled canvas so nothing is scaled
|
|
# up. No alpha channel: an app icon with one is rejected on upload.
|
|
img = img.crop((inset, inset, W - inset, W - inset)).resize((S, S), Image.LANCZOS).convert('RGB')
|
|
|
|
def write_json(path, value):
|
|
# Xcode's own layout, so opening the catalog in Xcode changes nothing.
|
|
with open(path, 'w') as f:
|
|
f.write(json.dumps(value, indent=2, separators=(',', ' : '), sort_keys=True) + '\n')
|
|
|
|
info = {'author': 'xcode', 'version': 1}
|
|
iconset = os.path.join(CATALOG, 'AppIcon.appiconset')
|
|
os.makedirs(iconset, exist_ok=True)
|
|
png = os.path.join(iconset, 'AppIcon.png')
|
|
img.save(png)
|
|
write_json(os.path.join(CATALOG, 'Contents.json'), {'info': info})
|
|
write_json(os.path.join(iconset, 'Contents.json'), {
|
|
'images': [{'filename': 'AppIcon.png', 'idiom': 'universal', 'platform': 'ios', 'size': '1024x1024'}],
|
|
'info': info,
|
|
})
|
|
print(png)
|