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>
237 lines
9.7 KiB
Python
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()
|