Files
ZenXercise/tools/icon/make_icon.py
T
CedricandClaude Opus 5.5 1deb2a43d0 App icon and a lavender Stretching palette
A lotus inside a dry-brush ensō, with a small plate in the gap and a small
barbell as the lifting hint. Drawn by tools/icon/make_icon.py and wired up
with flutter_launcher_icons; the launch screen uses the icon's lavender.
Stretching Mode moves from sage/teal to the icon's lavender and violet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 01:14:37 +01:00

237 lines
9.7 KiB
Python

"""Draws the ZenXercise app icon: a lotus inside a dry-brush ensō, with a hint of lifting.
Usage: python make_icon.py [output folder] (default: assets/icon)
Writes icon.png (legacy square icon on the lavender ground) and icon_foreground.png
(transparent adaptive-icon foreground, kept inside the launcher's safe zone).
Everything is drawn at 4x and scaled down, which smooths the edges. All randomness is
seeded, so the icon is identical on every run.
"""
from __future__ import annotations
import math
import random
import sys
from pathlib import Path
from PIL import Image, ImageDraw
SIZE = 1024
SCALE = 4
# Stretching's palette.
GROUND = (241, 238, 246, 255) # Lavender mist: the icon's background and the launch screen
VIOLET = (107, 91, 149, 255) # The ensō and the lotus's centre petal
VIOLET_LIGHT = (164, 150, 200, 255) # Side petals: a tonal step of the violet
APRICOT = (233, 138, 111, 255) # The lifting hint only: the plate and the small barbell
def _s(v: float) -> float:
return v * SCALE
class _Noise:
"""Smooth 1-D value noise in -1..1."""
def __init__(self, seed: int, size: int = 512):
rng = random.Random(seed)
self.values = [rng.uniform(-1, 1) for _ in range(size)]
def __call__(self, x: float) -> float:
i = math.floor(x)
f = x - i
a = self.values[i % len(self.values)]
b = self.values[(i + 1) % len(self.values)]
return a + (b - a) * (1 - math.cos(math.pi * f)) / 2
def dry_brush_enso(
draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, width: float, gap_deg: float, gap_at: float
):
"""An ensō painted counter-clockwise from beside the gap.
The opening of [gap_deg] degrees is centred on [gap_at] (degrees, 0 = right, counter-clockwise).
The brush lands with a round start and calm, nearly smooth edges; from halfway it runs dry:
its bristles stop overlapping, so pale streaks open inside the stroke, and the bristles give
out at different points, fraying the tail.
"""
serration, nicks, swell, wander = _Noise(3), _Noise(29), _Noise(11), _Noise(19)
rng = random.Random(13)
start = math.radians(gap_at + gap_deg / 2)
sweep = math.radians(360 - gap_deg)
dry = 0.5 # Where the brush starts to run out of ink.
def angle(t: float) -> float:
return start + sweep * t
def centre(t: float) -> float:
return r * (1 + 0.01 * wander(t * 3))
def stroke_width(t: float) -> float:
return width * (0.93 + 0.1 * swell(t * 3.5)) * (1 - 0.28 * (max(0.0, t - dry) / (1 - dry)) ** 1.6)
def point(t: float, d: float) -> tuple[float, float]:
a = angle(t)
return (_s(cx + d * math.cos(a)), _s(cy - d * math.sin(a)))
def ragged(t: float, side: int) -> float:
"""A faint irregularity on both edges, and the rare small nick on the outside."""
fine = 0.006 * serration(t * 40 + side * 31) + 0.002 * serration(t * 120 + side * 77)
nick = max(0.0, nicks(t * 9 + side * 5) - 0.8) * (0.03 if side > 0 else 0.0)
return (fine - nick) * min(1.0, t / 0.08)
steps = 1400
# Solid body, overlapping the bristles where they take over, with a round start.
ts = [i * (dry + 0.06) / steps for i in range(steps + 1)]
outer = [point(t, centre(t) + stroke_width(t) * (0.5 + ragged(t, 1))) for t in ts]
inner = [point(t, centre(t) - stroke_width(t) * (0.5 + ragged(t, -1))) for t in ts]
draw.polygon(outer + inner[::-1], fill=VIOLET)
a0, w0, d0 = angle(0.0), stroke_width(0.0), centre(0.0)
x0, y0 = cx + d0 * math.cos(a0), cy - d0 * math.sin(a0)
draw.ellipse([_s(x0 - w0 / 2), _s(y0 - w0 / 2), _s(x0 + w0 / 2), _s(y0 + w0 / 2)], fill=VIOLET)
# Bristles from the dry point on.
count = 15
for k in range(count):
frac = -0.5 + (k + 0.5) / count
edge = abs(frac) * 2
ink = rng.random()
end = 0.95 - 0.05 * rng.random() - 0.16 * edge**1.6 # Edge bristles give out sooner.
skips = []
for _ in range(rng.randint(0, 2)):
s0 = dry + 0.22 + rng.random() * max(0.0, end - dry - 0.3)
skips.append((s0, s0 + 0.008 + rng.random() * 0.025 * (1.2 - ink)))
phase = rng.random() * math.tau
def edges(t: float) -> tuple[float, float]:
w = stroke_width(t)
dried = max(0.0, (t - dry) / (end - dry))
# Full overlap while inked; separating as the bristle dries out.
half = 0.5 / count * (1.8 - 1.25 * dried ** (0.7 + ink)) * max(0.0, min(1.0, (end - t) / 0.12)) ** 0.8
# Lifting off the paper and touching down again: taper into and out of each skip.
for a, b in skips:
near = max(a - t, t - b, 0.0)
half *= min(1.0, near / 0.018) ** 0.7
splay = w * 0.005 * dried * math.sin(2 * math.pi * 6 * t + phase)
mid = centre(t) + w * frac + splay
# Never past the stroke's own edges, so the body hands over without a step.
top = min(mid + w * half, centre(t) + w * (0.5 + ragged(t, 1)))
bottom = max(mid - w * half, centre(t) - w * (0.5 + ragged(t, -1)))
return top, min(bottom, top)
run: list[float] = []
def flush():
if len(run) > 1:
draw.polygon([point(t, edges(t)[0]) for t in run] + [point(t, edges(t)[1]) for t in reversed(run)], fill=VIOLET)
run.clear()
for i in range(steps + 1):
t = dry + (end - dry) * i / steps
if any(a <= t <= b for a, b in skips):
flush()
else:
run.append(t)
flush()
def plate(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, ground):
"""A weight plate seen from the front: a disc with a centre hole."""
draw.ellipse([_s(cx - r), _s(cy - r), _s(cx + r), _s(cy + r)], fill=APRICOT)
hole = r * 0.3
draw.ellipse([_s(cx - hole), _s(cy - hole), _s(cx + hole), _s(cy + hole)], fill=ground)
class Pen:
"""Draws in 1024-unit coordinates around the centre, shrunk by [scale]."""
def __init__(self, draw: ImageDraw.ImageDraw, scale: float):
self.draw = draw
self.scale = scale
def p(self, x: float, y: float) -> tuple[float, float]:
c = SIZE / 2
return (_s(c + (x - c) * self.scale), _s(c + (y - c) * self.scale))
def w(self, v: float) -> float:
return _s(v * self.scale)
def circle(self, x, y, r, fill):
cx, cy = self.p(x, y)
rr = self.w(r)
self.draw.ellipse([cx - rr, cy - rr, cx + rr, cy + rr], fill=fill)
def limb(self, points, width, fill):
"""A thick stroke through [points] with round ends and joints."""
pts = [self.p(x, y) for x, y in points]
self.draw.line(pts, fill=fill, width=int(self.w(width)), joint="curve")
for x, y in points:
self.circle(x, y, width / 2, fill)
def bar(self, x0, x1, y, thickness, fill, plates=1.0):
"""A horizontal barbell: the bar, a big and a small plate at each end, sized by [plates]."""
self.limb([(x0, y), (x1, y)], thickness, fill)
for side, end in ((-1, x0), (1, x1)):
for offset, w, h in ((-28 * plates, 34 * plates, 120 * plates), (4 * plates, 24 * plates, 84 * plates)):
px = end - side * offset
a, b = self.p(px - w / 2, y - h / 2)
c, d = self.p(px + w / 2, y + h / 2)
self.draw.rounded_rectangle([a, b, c, d], radius=self.w(w / 2.4), fill=fill)
def petal(self, bx, by, length, width, angle_deg, fill):
"""An almond-shaped petal growing from (bx, by) towards [angle_deg] (90 = up)."""
a = math.radians(angle_deg)
ux, uy = math.cos(a), -math.sin(a)
nx, ny = -uy, ux
left, right = [], []
steps = 80
for i in range(steps + 1):
t = i / steps
half = width / 2 * math.sin(math.pi * t) ** 0.75 * (1 - 0.25 * t)
x, y = bx + ux * length * t, by + uy * length * t
left.append(self.p(x + nx * half, y + ny * half))
right.append(self.p(x - nx * half, y - ny * half))
self.draw.polygon(left + right[::-1], fill=fill)
def lotus(pen: Pen, base: float) -> None:
"""Five petals growing from (512, [base]): a deep centre, lighter sides."""
for angle, length, width, fill in (
(162, 250, 150, VIOLET_LIGHT),
(18, 250, 150, VIOLET_LIGHT),
(128, 300, 160, VIOLET_LIGHT),
(52, 300, 160, VIOLET_LIGHT),
(90, 360, 190, VIOLET),
):
pen.petal(512, base, length, width, angle, fill)
def draw_icon(ground, scale: float) -> Image.Image:
"""The motif on [ground]; [scale] shrinks it to fit the adaptive safe zone."""
image = Image.new("RGBA", (SIZE * SCALE, SIZE * SCALE), ground)
draw = ImageDraw.Draw(image)
c = SIZE / 2
r = 300 * scale
plate_at = 58 # Upper right, in the ensō's open gap.
# The stroke starts 29° past the plate and runs on until its dry tail gives out close to it.
dry_brush_enso(draw, c, c, r, 74 * scale, gap_deg=44, gap_at=plate_at + 7)
lotus(Pen(draw, scale * 0.7), base=660)
Pen(draw, scale * 0.7).bar(382, 642, 730, 16, APRICOT, plates=0.5)
a = math.radians(plate_at)
plate(draw, c + r * math.cos(a), c - r * math.sin(a), 46 * scale, ground)
return image.resize((SIZE, SIZE), Image.LANCZOS)
def main() -> None:
out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).resolve().parents[2] / "assets" / "icon"
out.mkdir(parents=True, exist_ok=True)
# Legacy icon: full bleed on the lavender ground.
draw_icon(GROUND, 1.0).save(out / "icon.png")
# Adaptive foreground: transparent, shrunk into the central safe zone (about 61% of the canvas).
draw_icon((0, 0, 0, 0), 0.68).save(out / "icon_foreground.png")
print(f"icon -> {out}")
if __name__ == "__main__":
main()