174 lines
5.3 KiB
Python
174 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
||
"""Generate Bugger app icon (bug beetle) in all required sizes for macOS."""
|
||
import math, os, shutil
|
||
from pathlib import Path
|
||
from PIL import Image, ImageDraw
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
ICONSET = ROOT / "Resources" / "Assets.xcassets" / "AppIcon.appiconset"
|
||
|
||
# Required macOS icon sizes: (logical_size, scale_factor, actual_pixels)
|
||
SIZES = [
|
||
(16, 1, 16), (16, 2, 32),
|
||
(32, 1, 32), (32, 2, 64),
|
||
(128, 1, 128), (128, 2, 256),
|
||
(256, 1, 256), (256, 2, 512),
|
||
(512, 1, 512), (512, 2, 1024),
|
||
]
|
||
|
||
|
||
def draw_bug(canvas_size: int) -> Image.Image:
|
||
"""Draw a bug beetle icon at given canvas size. Returns RGBA image."""
|
||
img = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0))
|
||
d = ImageDraw.Draw(img)
|
||
s = canvas_size # shorthand
|
||
|
||
# Colors
|
||
body_fill = (45, 50, 55, 255) # dark charcoal
|
||
body_highlight = (65, 72, 78, 255) # lighter shell
|
||
accent_green = (76, 188, 128, 255) # green accent
|
||
antenna = (45, 50, 55, 255)
|
||
leg = (45, 50, 55, 255)
|
||
eye = (255, 255, 255, 255)
|
||
|
||
# Margins (proportional)
|
||
margin = s * 0.12
|
||
|
||
# ---- Body (rounded oval) ----
|
||
body_rect = (
|
||
margin + s * 0.08, # left
|
||
margin + s * 0.22, # top
|
||
s - margin - s * 0.08, # right
|
||
s - margin + s * 0.05, # bottom
|
||
)
|
||
d.rounded_rectangle(body_rect, radius=s * 0.28, fill=body_fill)
|
||
|
||
# ---- Shell highlight (lighter arc on top half) ----
|
||
shell_rect = (
|
||
margin + s * 0.06,
|
||
margin + s * 0.22,
|
||
s - margin - s * 0.06,
|
||
s * 0.62,
|
||
)
|
||
d.pieslice(shell_rect, start=180, end=360, fill=body_highlight)
|
||
|
||
# ---- Green accent stripe ----
|
||
stripe_rect = (
|
||
s * 0.32,
|
||
s * 0.38,
|
||
s * 0.68,
|
||
s * 0.62,
|
||
)
|
||
d.rounded_rectangle(stripe_rect, radius=s * 0.06, fill=accent_green)
|
||
|
||
# ---- Head (small semi-circle at top) ----
|
||
head_center = (s * 0.50, s * 0.30)
|
||
head_r = s * 0.095
|
||
d.ellipse(
|
||
(head_center[0] - head_r, head_center[1] - head_r,
|
||
head_center[0] + head_r, head_center[1] + head_r),
|
||
fill=body_fill
|
||
)
|
||
|
||
# ---- Eyes ----
|
||
eye_r = s * 0.025
|
||
eye_offset = s * 0.04
|
||
for ex in (head_center[0] - eye_offset, head_center[0] + eye_offset):
|
||
d.ellipse(
|
||
(ex - eye_r, head_center[1] - eye_r - s * 0.01,
|
||
ex + eye_r, head_center[1] + eye_r - s * 0.01),
|
||
fill=eye
|
||
)
|
||
|
||
# ---- Antennae (two curved lines) ----
|
||
ant_line_w = max(2, int(s * 0.015))
|
||
ant_start_y = head_center[1] - head_r + s * 0.01
|
||
for side, curve_dir in [(-1, -0.08), (1, 0.08)]:
|
||
ant_start_x = head_center[0] + side * s * 0.04
|
||
points = [
|
||
(ant_start_x, ant_start_y),
|
||
(ant_start_x + side * s * 0.06, ant_start_y - s * 0.08),
|
||
(ant_start_x + side * s * 0.14, ant_start_y - s * 0.18 + curve_dir * s),
|
||
(ant_start_x + side * s * 0.18 + curve_dir * s * 0.5, ant_start_y - s * 0.17),
|
||
]
|
||
for i in range(len(points) - 1):
|
||
d.line([points[i], points[i + 1]], fill=antenna, width=ant_line_w)
|
||
# dot at tip
|
||
tip_r = max(1, int(s * 0.012))
|
||
tip = points[-1]
|
||
d.ellipse((tip[0] - tip_r, tip[1] - tip_r, tip[0] + tip_r, tip[1] + tip_r),
|
||
fill=antenna)
|
||
|
||
# ---- Legs (3 per side) ----
|
||
leg_w = max(2, int(s * 0.018))
|
||
leg_positions = [0.45, 0.56, 0.67] # y-positions as fraction of body height
|
||
body_left = margin + s * 0.02
|
||
body_right = s - margin - s * 0.02
|
||
|
||
for frac in leg_positions:
|
||
leg_y = s * (0.22 + frac * 0.57)
|
||
# Left legs
|
||
d.line(
|
||
[(body_left, leg_y),
|
||
(body_left - s * 0.16, leg_y - s * 0.08),
|
||
(body_left - s * 0.22, leg_y + s * 0.04)],
|
||
fill=leg, width=leg_w
|
||
)
|
||
# Right legs
|
||
d.line(
|
||
[(body_right, leg_y),
|
||
(body_right + s * 0.16, leg_y - s * 0.08),
|
||
(body_right + s * 0.22, leg_y + s * 0.04)],
|
||
fill=leg, width=leg_w
|
||
)
|
||
|
||
return img
|
||
|
||
|
||
def main():
|
||
ICONSET.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Generate the largest size and downscale
|
||
master = draw_bug(1024)
|
||
|
||
images = []
|
||
for logical, scale, pixels in SIZES:
|
||
if pixels == 1024:
|
||
img = master
|
||
else:
|
||
img = master.resize((pixels, pixels), Image.LANCZOS)
|
||
|
||
filename = f"icon_{logical}x{logical}@x{scale}.png"
|
||
filepath = ICONSET / filename
|
||
img.save(filepath, "PNG")
|
||
images.append({
|
||
"size": f"{logical}x{logical}",
|
||
"scale": f"{scale}x",
|
||
"filename": filename,
|
||
})
|
||
print(f" {filename} ({pixels}×{pixels})")
|
||
|
||
# Update Contents.json
|
||
contents = {
|
||
"images": [],
|
||
"info": {"author": "xcode", "version": 1},
|
||
}
|
||
for img in images:
|
||
contents["images"].append({
|
||
"size": img["size"],
|
||
"idiom": "mac",
|
||
"filename": img["filename"],
|
||
"scale": img["scale"],
|
||
})
|
||
|
||
import json
|
||
contents_path = ICONSET / "Contents.json"
|
||
with open(contents_path, "w") as f:
|
||
json.dump(contents, f, indent=2)
|
||
print(f"\nWrote {contents_path}")
|
||
print(f"Generated {len(images)} icon sizes in {ICONSET}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|