Files
Screens/icons/generate.py
Trey T 0f415ab498 Add 5 icon concepts for review
Generated via icons/generate.py. See PHILOSOPHY.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:33:13 -05:00

629 lines
23 KiB
Python

"""
Icon generator for Screens — 5 distinct 1024x1024 iOS app icons.
Philosophy: Luminous Distance (see PHILOSOPHY.md).
Each icon fills the canvas edge-to-edge. iOS applies the squircle mask.
"""
from __future__ import annotations
import math, os
from PIL import Image, ImageDraw, ImageFilter, ImageChops
SIZE = 1024
OUT = os.path.dirname(os.path.abspath(__file__))
# ---------------------------------------------------------------- helpers
def blank(size=SIZE):
return Image.new("RGBA", (size, size), (0, 0, 0, 0))
def hex_to_rgba(h, a=255):
h = h.lstrip("#")
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), a)
def lerp(a, b, t):
return a + (b - a) * t
def lerp_color(c1, c2, t):
return tuple(int(lerp(c1[i], c2[i], t)) for i in range(len(c1)))
def linear_gradient(size, stops, angle_deg=90):
"""stops = [(t, (r,g,b,a)), ...] with t in [0,1]; angle 0=→, 90=↓"""
w = h = size
img = Image.new("RGBA", (w, h))
px = img.load()
rad = math.radians(angle_deg)
dx, dy = math.cos(rad), math.sin(rad)
# Project (x,y) onto the direction vector, normalize to [0,1]
cx, cy = w / 2, h / 2
# half-extent in direction
ext = abs(dx) * w / 2 + abs(dy) * h / 2
for y in range(h):
for x in range(w):
t = ((x - cx) * dx + (y - cy) * dy) / (2 * ext) + 0.5
t = max(0.0, min(1.0, t))
# find segment
for i in range(len(stops) - 1):
t0, c0 = stops[i]
t1, c1 = stops[i + 1]
if t0 <= t <= t1:
local = (t - t0) / (t1 - t0) if t1 > t0 else 0
px[x, y] = lerp_color(c0, c1, local)
break
else:
px[x, y] = stops[-1][1]
return img
def radial_gradient(size, center, stops, radius=None):
w = h = size
if radius is None:
radius = size * 0.75
img = Image.new("RGBA", (w, h))
px = img.load()
cx, cy = center
for y in range(h):
for x in range(w):
dx, dy = x - cx, y - cy
d = math.sqrt(dx * dx + dy * dy) / radius
d = max(0.0, min(1.0, d))
for i in range(len(stops) - 1):
t0, c0 = stops[i]
t1, c1 = stops[i + 1]
if t0 <= d <= t1:
local = (d - t0) / (t1 - t0) if t1 > t0 else 0
px[x, y] = lerp_color(c0, c1, local)
break
else:
px[x, y] = stops[-1][1]
return img
def rounded_rect_mask(size, box, radius):
mask = Image.new("L", size, 0)
d = ImageDraw.Draw(mask)
d.rounded_rectangle(box, radius=radius, fill=255)
return mask
def drop_shadow(shape_mask, offset=(0, 12), blur=40, opacity=120):
sh = Image.new("RGBA", shape_mask.size, (0, 0, 0, 0))
sh.putalpha(shape_mask.point(lambda v: int(v * opacity / 255)))
sh = sh.filter(ImageFilter.GaussianBlur(blur))
offset_mask = Image.new("RGBA", shape_mask.size, (0, 0, 0, 0))
offset_mask.paste(sh, offset, sh)
return offset_mask
def specular_highlight(mask, intensity=110):
"""Top-inner glow hinting at glass."""
w, h = mask.size
grad = Image.new("L", (w, h))
for y in range(h):
v = int(255 * max(0, 1 - y / (h * 0.55)) ** 2 * (intensity / 255))
for x in range(w):
grad.putpixel((x, y), v)
highlight = Image.new("RGBA", (w, h), (255, 255, 255, 0))
highlight.putalpha(ImageChops.multiply(grad, mask))
return highlight
def paste_masked(base, layer, mask):
"""Paste `layer` onto `base` through `mask` (L mode)."""
combined = Image.new("RGBA", base.size, (0, 0, 0, 0))
combined.paste(layer, (0, 0), mask)
return Image.alpha_composite(base, combined)
def noise_layer(size, amount=6):
"""Fine film grain for tactile quality."""
import random
random.seed(42)
layer = Image.new("L", (size, size))
px = layer.load()
for y in range(size):
for x in range(size):
px[x, y] = 128 + random.randint(-amount, amount)
noise = Image.new("RGBA", (size, size), (255, 255, 255, 0))
noise.putalpha(layer.point(lambda v: abs(v - 128) * 2))
return noise
# ---------------------------------------------------------------- icon 1 — Portal
def icon_portal():
canvas = linear_gradient(SIZE, [
(0.0, hex_to_rgba("#0B0B2A")),
(0.6, hex_to_rgba("#1E1757")),
(1.0, hex_to_rgba("#2A0E4D")),
], angle_deg=115)
# Soft aura behind the portal
aura = radial_gradient(SIZE, (SIZE / 2, SIZE / 2 - 20), [
(0.0, hex_to_rgba("#7B6DF5", 230)),
(0.45, hex_to_rgba("#5B3FC0", 100)),
(1.0, hex_to_rgba("#140B36", 0)),
], radius=SIZE * 0.55)
canvas = Image.alpha_composite(canvas, aura)
# Screen rectangle
inset = 200
box = (inset, inset + 40, SIZE - inset, SIZE - inset - 40)
radius = 70
# Back-lit glow
glow_mask = rounded_rect_mask((SIZE, SIZE), (box[0] - 40, box[1] - 40, box[2] + 40, box[3] + 40), radius + 30)
glow = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
glow.putalpha(glow_mask.filter(ImageFilter.GaussianBlur(36)).point(lambda v: int(v * 0.85)))
glow_layer = Image.new("RGBA", (SIZE, SIZE), hex_to_rgba("#8A7BFF", 200))
glow_layer.putalpha(glow.getchannel("A"))
canvas = Image.alpha_composite(canvas, glow_layer)
# Screen surface gradient
screen = linear_gradient(SIZE, [
(0.0, hex_to_rgba("#CBB8FF")),
(0.35, hex_to_rgba("#7B63F0")),
(1.0, hex_to_rgba("#2B1C6B")),
], angle_deg=112)
screen_mask = rounded_rect_mask((SIZE, SIZE), box, radius)
canvas = paste_masked(canvas, screen, screen_mask)
# Inner bezel / rim light
rim_outer = rounded_rect_mask((SIZE, SIZE), box, radius)
inset_box = (box[0] + 8, box[1] + 8, box[2] - 8, box[3] - 8)
rim_inner = rounded_rect_mask((SIZE, SIZE), inset_box, radius - 8)
rim = ImageChops.subtract(rim_outer, rim_inner)
rim_layer = Image.new("RGBA", (SIZE, SIZE), hex_to_rgba("#E4D8FF", 150))
rim_layer.putalpha(rim)
canvas = Image.alpha_composite(canvas, rim_layer)
# Specular sheen (top-inner highlight, clipped to screen)
sheen = specular_highlight(screen_mask, intensity=90)
canvas = Image.alpha_composite(canvas, sheen)
# Inner scanline bloom — a soft horizontal ribbon implying content
ribbon_box = (box[0] + 80, int((box[1] + box[3]) / 2) - 3,
box[2] - 80, int((box[1] + box[3]) / 2) + 3)
ribbon_mask = rounded_rect_mask((SIZE, SIZE), ribbon_box, 3).filter(ImageFilter.GaussianBlur(4))
ribbon_layer = Image.new("RGBA", (SIZE, SIZE), hex_to_rgba("#FFFFFF", 180))
ribbon_layer.putalpha(ribbon_mask)
canvas = Image.alpha_composite(canvas, ribbon_layer)
canvas.save(os.path.join(OUT, "icon-1-portal.png"))
# ---------------------------------------------------------------- icon 2 — Cursor
def icon_cursor():
canvas = linear_gradient(SIZE, [
(0.0, hex_to_rgba("#2E36E8")),
(0.5, hex_to_rgba("#9B38E8")),
(1.0, hex_to_rgba("#FF5A8F")),
], angle_deg=135)
# Additional top-left glow to lift the cursor tip
glow = radial_gradient(SIZE, (SIZE * 0.32, SIZE * 0.30), [
(0.0, hex_to_rgba("#FFFFFF", 160)),
(0.5, hex_to_rgba("#B9B0FF", 60)),
(1.0, hex_to_rgba("#3A2C9A", 0)),
], radius=SIZE * 0.6)
canvas = Image.alpha_composite(canvas, glow)
# Classic macOS arrow cursor — authored as polygon coordinates on a 100-unit grid
cursor_norm = [
(0, 0), (0, 73), (20, 56), (32, 85), (44, 80), (33, 52), (56, 52)
]
# Scale + center the cursor
scale = 7.0
cursor_w = 56 * scale
cursor_h = 85 * scale
ox = (SIZE - cursor_w) / 2 - 20
oy = (SIZE - cursor_h) / 2 - 20
pts = [(ox + x * scale, oy + y * scale) for (x, y) in cursor_norm]
# Soft shadow behind cursor
shadow_mask = Image.new("L", (SIZE, SIZE), 0)
ImageDraw.Draw(shadow_mask).polygon([(p[0] + 16, p[1] + 24) for p in pts], fill=255)
shadow_mask = shadow_mask.filter(ImageFilter.GaussianBlur(22))
shadow_layer = Image.new("RGBA", (SIZE, SIZE), (20, 10, 60, 0))
shadow_layer.putalpha(shadow_mask.point(lambda v: int(v * 0.55)))
canvas = Image.alpha_composite(canvas, shadow_layer)
# Cursor outline (thin dark) and fill (white) — draw as two polygons
cursor_outer = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
od = ImageDraw.Draw(cursor_outer)
# Slight outline outset — simulate with stroked polygon
# Draw outline by expanding the polygon by ~stroke/2 via line draw
stroke = 18
od.polygon(pts, fill=(30, 14, 80, 255))
# Now shrink by drawing the white body slightly inset — simplest: re-use polygon, paint white with drawn stroke=0
cursor_body = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
bd = ImageDraw.Draw(cursor_body)
# Inset polygon: compute centroid and pull points toward it by `stroke*0.35` for subtle inset
cx = sum(p[0] for p in pts) / len(pts)
cy = sum(p[1] for p in pts) / len(pts)
inset = stroke * 0.35
inset_pts = []
for (x, y) in pts:
dx, dy = x - cx, y - cy
d = math.hypot(dx, dy)
if d == 0:
inset_pts.append((x, y))
else:
inset_pts.append((x - dx / d * inset, y - dy / d * inset))
bd.polygon(inset_pts, fill=(255, 255, 255, 255))
# Cursor highlight gradient over the body
body_mask = Image.new("L", (SIZE, SIZE), 0)
ImageDraw.Draw(body_mask).polygon(inset_pts, fill=255)
body_grad = linear_gradient(SIZE, [
(0.0, hex_to_rgba("#FFFFFF")),
(1.0, hex_to_rgba("#D8D4FF")),
], angle_deg=110)
cursor_body_grad = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
cursor_body_grad.paste(body_grad, (0, 0), body_mask)
canvas = Image.alpha_composite(canvas, cursor_outer)
canvas = Image.alpha_composite(canvas, cursor_body_grad)
canvas.save(os.path.join(OUT, "icon-2-cursor.png"))
# ---------------------------------------------------------------- icon 3 — Concentric Waves
def icon_waves():
canvas = linear_gradient(SIZE, [
(0.0, hex_to_rgba("#031028")),
(1.0, hex_to_rgba("#072047")),
], angle_deg=100)
# Deep radial dim at the edges for vignette
edge = radial_gradient(SIZE, (SIZE / 2, SIZE / 2), [
(0.0, hex_to_rgba("#000000", 0)),
(0.65, hex_to_rgba("#000000", 0)),
(1.0, hex_to_rgba("#000000", 120)),
], radius=SIZE * 0.75)
canvas = Image.alpha_composite(canvas, edge)
# Concentric rounded rectangles — 5 rings, smallest is bright, biggest is subtle
rings = [
{"inset": 120, "radius": 160, "stroke": 18, "color": hex_to_rgba("#1AA3C2", 110)},
{"inset": 210, "radius": 130, "stroke": 16, "color": hex_to_rgba("#1BC6E0", 150)},
{"inset": 300, "radius": 100, "stroke": 14, "color": hex_to_rgba("#31E1F5", 190)},
{"inset": 380, "radius": 74, "stroke": 12, "color": hex_to_rgba("#7EF0FF", 230)},
]
for r in rings:
box = (r["inset"], r["inset"], SIZE - r["inset"], SIZE - r["inset"])
outer = rounded_rect_mask((SIZE, SIZE), box, r["radius"])
inset_box = (box[0] + r["stroke"], box[1] + r["stroke"], box[2] - r["stroke"], box[3] - r["stroke"])
inner = rounded_rect_mask((SIZE, SIZE), inset_box, max(r["radius"] - r["stroke"], 10))
ring = ImageChops.subtract(outer, inner)
# Subtle blur — outer rings softer than inner
blur_amt = 0.8 + (rings.index(r)) * 0.3
ring = ring.filter(ImageFilter.GaussianBlur(blur_amt))
layer = Image.new("RGBA", (SIZE, SIZE), r["color"][:3] + (0,))
layer.putalpha(ring.point(lambda v, a=r["color"][3]: int(v * a / 255)))
canvas = Image.alpha_composite(canvas, layer)
# Central glowing dot
dot_r = 28
dot_box = (SIZE / 2 - dot_r, SIZE / 2 - dot_r, SIZE / 2 + dot_r, SIZE / 2 + dot_r)
dot_mask = Image.new("L", (SIZE, SIZE), 0)
ImageDraw.Draw(dot_mask).ellipse(dot_box, fill=255)
dot_mask = dot_mask.filter(ImageFilter.GaussianBlur(2))
dot_layer = Image.new("RGBA", (SIZE, SIZE), hex_to_rgba("#E8FDFF"))
dot_layer.putalpha(dot_mask)
canvas = Image.alpha_composite(canvas, dot_layer)
# Dot bloom
bloom_mask = Image.new("L", (SIZE, SIZE), 0)
ImageDraw.Draw(bloom_mask).ellipse((SIZE / 2 - 90, SIZE / 2 - 90, SIZE / 2 + 90, SIZE / 2 + 90), fill=255)
bloom_mask = bloom_mask.filter(ImageFilter.GaussianBlur(36))
bloom_layer = Image.new("RGBA", (SIZE, SIZE), hex_to_rgba("#7EF0FF"))
bloom_layer.putalpha(bloom_mask.point(lambda v: int(v * 0.45)))
canvas = Image.alpha_composite(canvas, bloom_layer)
canvas.save(os.path.join(OUT, "icon-3-waves.png"))
# ---------------------------------------------------------------- icon 4 — Monogram S
def icon_monogram():
canvas = linear_gradient(SIZE, [
(0.0, hex_to_rgba("#0A1430")),
(0.5, hex_to_rgba("#1B1450")),
(1.0, hex_to_rgba("#04061A")),
], angle_deg=125)
# Chromatic bloom — blue, magenta, amber — behind the mark
bloom1 = radial_gradient(SIZE, (SIZE * 0.28, SIZE * 0.30), [
(0.0, hex_to_rgba("#4AA8FF", 190)),
(1.0, hex_to_rgba("#080822", 0)),
], radius=SIZE * 0.55)
bloom2 = radial_gradient(SIZE, (SIZE * 0.78, SIZE * 0.78), [
(0.0, hex_to_rgba("#E845A8", 170)),
(1.0, hex_to_rgba("#080822", 0)),
], radius=SIZE * 0.55)
canvas = Image.alpha_composite(canvas, bloom1)
canvas = Image.alpha_composite(canvas, bloom2)
# Two interlocking chevrons that read as an "S"
# Upper chevron (like a `>` opening right) at top
# Lower chevron (like a `<` opening left) at bottom — stacked with overlap
stroke = 110
inset_x = 200
center_y = SIZE / 2
top_y = 260
bot_y = SIZE - 260
def chevron_points(start_x, end_x, tip_x, top, bottom, thickness):
"""A V-shaped chevron band from (start_x, top) → (tip_x, mid) → (end_x, top)
drawn as a parallelogram-like thick stroke."""
mid = (top + bottom) / 2
return [
(start_x, top),
(tip_x, bottom),
(end_x, top),
(end_x, top + thickness),
(tip_x, bottom + thickness), # overflowed — we'll draw differently
(start_x, top + thickness),
]
# Rather than manual polygons, paint two thick rounded "V" strokes.
upper = Image.new("L", (SIZE, SIZE), 0)
lower = Image.new("L", (SIZE, SIZE), 0)
ud = ImageDraw.Draw(upper)
ld = ImageDraw.Draw(lower)
# Upper chevron: start top-left → tip right-middle → continues back to top-right? Actually we want ">"
# We'll draw the S as two mirrored arcs — easier: use thick polylines along a cubic path.
# Approximate with chained line segments + round caps.
def thick_polyline(draw, pts, width):
for i in range(len(pts) - 1):
draw.line([pts[i], pts[i + 1]], fill=255, width=width)
for p in pts:
r = width / 2
draw.ellipse((p[0] - r, p[1] - r, p[0] + r, p[1] + r), fill=255)
# An abstract "S": top arc (left-top → right-center), bottom arc (right-center → left-bottom)
top_arc = [
(inset_x + 20, top_y),
(SIZE - inset_x - 80, top_y + 80),
(SIZE - inset_x, center_y - 40),
(SIZE - inset_x - 40, center_y),
]
bot_arc = [
(SIZE - inset_x - 40, center_y),
(inset_x + 40, center_y + 40),
(inset_x, bot_y - 80),
(inset_x + 20, bot_y),
]
# Smooth via many interpolated bezier-like points
def smooth(points, samples=80):
# Quadratic bezier through 4 points: treat as cubic bezier control poly
(p0, p1, p2, p3) = points
out = []
for i in range(samples + 1):
t = i / samples
# cubic bezier
x = (1 - t) ** 3 * p0[0] + 3 * (1 - t) ** 2 * t * p1[0] + 3 * (1 - t) * t ** 2 * p2[0] + t ** 3 * p3[0]
y = (1 - t) ** 3 * p0[1] + 3 * (1 - t) ** 2 * t * p1[1] + 3 * (1 - t) * t ** 2 * p2[1] + t ** 3 * p3[1]
out.append((x, y))
return out
top_curve = smooth(top_arc, samples=120)
bot_curve = smooth(bot_arc, samples=120)
thick_polyline(ud, top_curve, stroke)
thick_polyline(ld, bot_curve, stroke)
combined = ImageChops.lighter(upper, lower)
# Chromatic fill: horizontal-diagonal gradient inside the S
s_grad = linear_gradient(SIZE, [
(0.0, hex_to_rgba("#7FB2FF")),
(0.5, hex_to_rgba("#C97BFF")),
(1.0, hex_to_rgba("#FF8BB8")),
], angle_deg=120)
s_layer = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
s_layer.paste(s_grad, (0, 0), combined)
# Highlight rim on top edge of the S for glass feel
rim = combined.filter(ImageFilter.GaussianBlur(1))
rim_inner = combined.filter(ImageFilter.GaussianBlur(3))
edge = ImageChops.subtract(rim, rim_inner)
edge_layer = Image.new("RGBA", (SIZE, SIZE), hex_to_rgba("#FFFFFF", 130))
edge_layer.putalpha(edge)
# Drop shadow under the S
shadow = combined.filter(ImageFilter.GaussianBlur(26))
sh_layer = Image.new("RGBA", (SIZE, SIZE), (0, 0, 20, 0))
sh_layer.putalpha(shadow.point(lambda v: int(v * 0.55)))
sh_offset = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
sh_offset.paste(sh_layer, (0, 16), sh_layer)
canvas = Image.alpha_composite(canvas, sh_offset)
canvas = Image.alpha_composite(canvas, s_layer)
canvas = Image.alpha_composite(canvas, edge_layer)
canvas.save(os.path.join(OUT, "icon-4-monogram.png"))
# ---------------------------------------------------------------- icon 5 — Split Screen
def icon_split():
canvas = linear_gradient(SIZE, [
(0.0, hex_to_rgba("#1A1245")),
(0.5, hex_to_rgba("#2F1D78")),
(1.0, hex_to_rgba("#0D0824")),
], angle_deg=120)
# Warm highlight in upper-left
warm = radial_gradient(SIZE, (SIZE * 0.28, SIZE * 0.22), [
(0.0, hex_to_rgba("#F7B57A", 140)),
(1.0, hex_to_rgba("#1A1245", 0)),
], radius=SIZE * 0.55)
canvas = Image.alpha_composite(canvas, warm)
# Cool counterpoint lower-right
cool = radial_gradient(SIZE, (SIZE * 0.82, SIZE * 0.82), [
(0.0, hex_to_rgba("#4E6CFF", 180)),
(1.0, hex_to_rgba("#100828", 0)),
], radius=SIZE * 0.6)
canvas = Image.alpha_composite(canvas, cool)
# Two tilted rectangles — "back" one tilted left, "front" one tilted right.
def rotate_polygon(pts, angle_deg, cx, cy):
a = math.radians(angle_deg)
cos_a, sin_a = math.cos(a), math.sin(a)
out = []
for (x, y) in pts:
dx, dy = x - cx, y - cy
nx = dx * cos_a - dy * sin_a + cx
ny = dx * sin_a + dy * cos_a + cy
out.append((nx, ny))
return out
def draw_screen(rect_box, tilt_deg, body_grad_stops, rim_alpha=140):
# rect_box: (x0,y0,x1,y1)
x0, y0, x1, y1 = rect_box
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
# Approximate rounded-rect by drawing on a tilted transparent layer
layer = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
mask = Image.new("L", (SIZE, SIZE), 0)
radius = 80
# Draw axis-aligned rounded rect on a working canvas sized to the screen's bounding box, then rotate.
local_w = int(x1 - x0) + 200
local_h = int(y1 - y0) + 200
local_mask = Image.new("L", (local_w, local_h), 0)
lx0 = 100
ly0 = 100
lx1 = local_w - 100
ly1 = local_h - 100
ImageDraw.Draw(local_mask).rounded_rectangle((lx0, ly0, lx1, ly1), radius=radius, fill=255)
local_mask = local_mask.rotate(tilt_deg, resample=Image.BICUBIC, expand=False)
# Place local mask into full-size mask at correct position
place_x = int(cx - local_w / 2)
place_y = int(cy - local_h / 2)
mask.paste(local_mask, (place_x, place_y))
# Body gradient
body = linear_gradient(SIZE, body_grad_stops, angle_deg=110)
# Shadow before composite
shadow_mask = mask.filter(ImageFilter.GaussianBlur(30))
shadow = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
shadow.putalpha(shadow_mask.point(lambda v: int(v * 0.55)))
sh_offset = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
sh_offset.paste(shadow, (14, 30), shadow)
body_layer = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
body_layer.paste(body, (0, 0), mask)
# Rim / edge highlight
rim_outer = mask
rim_inner = mask.filter(ImageFilter.GaussianBlur(2)).point(lambda v: 255 if v > 180 else 0)
rim_inner_eroded = rim_inner.filter(ImageFilter.MinFilter(9))
rim = ImageChops.subtract(rim_outer, rim_inner_eroded)
rim_layer = Image.new("RGBA", (SIZE, SIZE), hex_to_rgba("#FFFFFF", rim_alpha))
rim_layer.putalpha(rim)
return sh_offset, body_layer, rim_layer, mask
# Back screen: bigger, tilted left
back_box = (200, 250, SIZE - 240, SIZE - 200)
back = draw_screen(
back_box, tilt_deg=-7,
body_grad_stops=[
(0.0, hex_to_rgba("#5A4FD9")),
(1.0, hex_to_rgba("#1E154F")),
],
rim_alpha=90
)
# Front screen: smaller, tilted right, shifted down-right
front_box = (280, 330, SIZE - 160, SIZE - 160)
front = draw_screen(
front_box, tilt_deg=6,
body_grad_stops=[
(0.0, hex_to_rgba("#F5CE9A")),
(0.5, hex_to_rgba("#E086A3")),
(1.0, hex_to_rgba("#6B488C")),
],
rim_alpha=160
)
# Composite: back shadow → back body → back rim → front shadow → front body → front rim
for bundle in (back, front):
sh, body, rim, _ = bundle
canvas = Image.alpha_composite(canvas, sh)
canvas = Image.alpha_composite(canvas, body)
canvas = Image.alpha_composite(canvas, rim)
# Specular highlight on front screen
_, _, _, front_mask = front
sheen = specular_highlight(front_mask, intensity=120)
canvas = Image.alpha_composite(canvas, sheen)
canvas.save(os.path.join(OUT, "icon-5-split.png"))
# ---------------------------------------------------------------- contact sheet
def contact_sheet():
paths = [
"icon-1-portal.png",
"icon-2-cursor.png",
"icon-3-waves.png",
"icon-4-monogram.png",
"icon-5-split.png",
]
cell = 360
gap = 30
cols = 5
rows = 1
label_h = 70
W = cols * cell + (cols + 1) * gap
H = rows * cell + (rows + 1) * gap + label_h
sheet = Image.new("RGB", (W, H), (24, 24, 30))
d = ImageDraw.Draw(sheet)
for i, p in enumerate(paths):
img = Image.open(os.path.join(OUT, p)).convert("RGBA")
# Apply iOS squircle preview mask
mask = Image.new("L", img.size, 0)
ImageDraw.Draw(mask).rounded_rectangle((0, 0, img.size[0], img.size[1]), radius=int(img.size[0] * 0.22), fill=255)
img.putalpha(mask)
img = img.resize((cell, cell), Image.LANCZOS)
x = gap + i * (cell + gap)
y = gap
sheet.paste(img, (x, y), img)
label = ["1 Portal", "2 Cursor", "3 Waves", "4 Monogram", "5 Split"][i]
d.text((x + cell / 2 - 30, y + cell + 20), label, fill=(240, 240, 250))
sheet.save(os.path.join(OUT, "contact-sheet.png"))
if __name__ == "__main__":
print("Generating icons…")
icon_portal()
print(" ✓ portal")
icon_cursor()
print(" ✓ cursor")
icon_waves()
print(" ✓ waves")
icon_monogram()
print(" ✓ monogram")
icon_split()
print(" ✓ split")
contact_sheet()
print(" ✓ contact sheet")
print("Done.")