- Split render into FieldTable/Fields/Icons/Layout/Settings modules - Add Fenix8V3Background, Owm (weather), Solar modules - Add tools/ generators (gen_fields/gen_icons/gen_themes/gen_preview_svg) - Tune README/manifest/strings/settings
203 lines
7.7 KiB
Python
203 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
||
"""把设计稿的图标路径光栅化成可染色的位图资源。
|
||
|
||
design/*.svg 里的日出和天气图标是描边出来的轮廓,最细的特征只有 2–3 个设计
|
||
像素宽。用圆和线段去重画它们在 454px 上还行,到 280px 就彻底糊掉 —— 那个尺寸
|
||
下这些细节不足一个物理像素。
|
||
|
||
所以改成:把真实路径按每种屏宽各渲染一次,运行时用
|
||
drawBitmap2(:tintColor) 染上主题色。这样每台设备拿到的都是为它的像素网格
|
||
专门渲染的图。
|
||
|
||
python3 tools/gen_icons.py
|
||
|
||
产出 resources-icons-<屏宽>/drawables/{sunrise,sunset,weather}.png 以及配套的
|
||
drawables.xml;另外产出 resources-launcher-<尺寸>/ 下各机型要求尺寸的启动图标。
|
||
|
||
⚠️ 两个要点:
|
||
1. drawables.xml 里必须写 packingFormat="png"。否则资源编译器会把图片压成
|
||
调色板格式,而 :tintColor 拒绝调色板化的源,运行时直接抛异常。
|
||
2. MIP 屏没有 alpha 混合,所以那几个尺寸的覆盖率蒙版会被阈值化成硬边 ——
|
||
软边在 64 色调色板下反而更脏。
|
||
"""
|
||
import os
|
||
import re
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
ROOT = os.path.dirname(HERE)
|
||
SRC = os.path.join(ROOT, "design", "watchface-01-ember.svg")
|
||
|
||
try:
|
||
from PIL import Image
|
||
except ImportError:
|
||
raise SystemExit("this generator needs Pillow: python3 -m pip install pillow")
|
||
|
||
# The three icons the comps actually contain, and the design-space centre each
|
||
# one is drawn around. A common 32x32 design box keeps blitting trivial.
|
||
BOX = 32.0
|
||
ICONS = {
|
||
"sunrise": ("sunrise-icon", 156.32, 89.5, False),
|
||
"sunset": ("sunrise-icon", 156.32, 89.5, True), # same art, chevron flipped
|
||
"weather": ("weather-icon", 343.55, 89.5, False),
|
||
}
|
||
|
||
# screen width -> whether the panel can blend alpha (MIP cannot)
|
||
WIDTHS = {240: False, 260: False, 280: False, 390: True, 416: True, 454: True}
|
||
|
||
SS = 8 # supersampling factor
|
||
|
||
|
||
def subpaths(svg, pid):
|
||
d = re.search(r'<path id="%s" d="([^"]+)"' % pid, svg).group(1)
|
||
out = []
|
||
for chunk in d.split("Z"):
|
||
pts = [(float(x), float(y))
|
||
for x, y in re.findall(r"(-?\d+\.\d+),(-?\d+\.\d+)", chunk)]
|
||
if len(pts) >= 3:
|
||
out.append(pts)
|
||
return out
|
||
|
||
|
||
def flip_chevron(paths):
|
||
"""Mirror the horizon bar so its notch points up -- our sunset variant."""
|
||
out = []
|
||
for p in paths:
|
||
ys = [q[1] for q in p]
|
||
if min(ys) > 96.0: # the bar is the lowest subpath
|
||
mid = (min(ys) + max(ys)) / 2.0
|
||
p = [(x, 2 * mid - y) for x, y in p]
|
||
out.append(p)
|
||
return out
|
||
|
||
|
||
def scanline_evenodd(paths, size, ox, oy, scale):
|
||
"""Even-odd fill at supersampled resolution, returned as a coverage mask."""
|
||
n = size * SS
|
||
cov = [bytearray(n) for _ in range(n)]
|
||
edges = []
|
||
for p in paths:
|
||
for i in range(len(p)):
|
||
x0, y0 = p[i]
|
||
x1, y1 = p[(i + 1) % len(p)]
|
||
X0 = (x0 - ox) * scale * SS
|
||
Y0 = (y0 - oy) * scale * SS
|
||
X1 = (x1 - ox) * scale * SS
|
||
Y1 = (y1 - oy) * scale * SS
|
||
if Y0 != Y1:
|
||
edges.append((Y0, Y1, X0, X1))
|
||
for row in range(n):
|
||
yc = row + 0.5
|
||
xs = []
|
||
for Y0, Y1, X0, X1 in edges:
|
||
if (Y0 <= yc < Y1) or (Y1 <= yc < Y0):
|
||
xs.append(X0 + (yc - Y0) * (X1 - X0) / (Y1 - Y0))
|
||
if not xs:
|
||
continue
|
||
xs.sort()
|
||
line = cov[row]
|
||
for i in range(0, len(xs) - 1, 2):
|
||
a = max(0, int(round(xs[i])))
|
||
b = min(n, int(round(xs[i + 1])))
|
||
for x in range(a, b):
|
||
line[x] = 255
|
||
return cov
|
||
|
||
|
||
def render(paths, size, cx, cy, blend):
|
||
scale = size / BOX
|
||
ox, oy = cx - BOX / 2, cy - BOX / 2
|
||
cov = scanline_evenodd(paths, size, ox, oy, scale)
|
||
n = size * SS
|
||
big = Image.frombytes("L", (n, n), b"".join(bytes(r) for r in cov))
|
||
small = big.resize((size, size), Image.LANCZOS)
|
||
if not blend:
|
||
small = small.point(lambda v: 255 if v >= 110 else 0)
|
||
white = Image.new("RGB", (size, size), (255, 255, 255))
|
||
img = white.convert("RGBA")
|
||
img.putalpha(small)
|
||
return img
|
||
|
||
|
||
# Launcher icon sizes the target devices ask for.
|
||
LAUNCHER = [40, 56, 60, 65, 70]
|
||
|
||
|
||
def launcher(size):
|
||
"""A miniature of the face: the gradient ring plus the date band."""
|
||
from PIL import ImageDraw
|
||
ss = 8
|
||
n = size * ss
|
||
img = Image.new("RGBA", (n, n), (0, 0, 0, 0))
|
||
d = ImageDraw.Draw(img)
|
||
d.ellipse([0, 0, n - 1, n - 1], fill=(0, 0, 0, 255))
|
||
svg = open(SRC).read()
|
||
top = re.findall(r'fill="(#[0-9A-Fa-f]{6})"',
|
||
re.search(r'<g id="ring-top-right">(.*?)</g>', svg, re.S).group(1))
|
||
bot = re.findall(r'fill="(#[0-9A-Fa-f]{6})"',
|
||
re.search(r'<g id="ring-bottom-left">(.*?)</g>', svg, re.S).group(1))
|
||
ro, ri = n * 0.495, n * 0.375
|
||
box_o = [n / 2 - ro, n / 2 - ro, n / 2 + ro, n / 2 + ro]
|
||
# sweep the whole rim, mirroring the comp's two ramps into four quadrants
|
||
for q, ramp in ((0, top), (1, top), (2, bot), (3, bot)):
|
||
for i, c in enumerate(ramp):
|
||
col = tuple(int(c[1 + 2 * j:3 + 2 * j], 16) for j in range(3)) + (255,)
|
||
span = 90.0 / len(ramp)
|
||
if q == 0:
|
||
a0 = -90 + i * span
|
||
elif q == 1:
|
||
a0 = -90 - (i + 1) * span
|
||
elif q == 2:
|
||
a0 = 90 - (i + 1) * span
|
||
else:
|
||
a0 = 90 + i * span
|
||
d.pieslice(box_o, a0, a0 + span, fill=col)
|
||
d.ellipse([n / 2 - ri, n / 2 - ri, n / 2 + ri, n / 2 + ri], fill=(0, 0, 0, 255))
|
||
band = tuple(int("FEAA00"[2 * j:2 * j + 2], 16) for j in range(3)) + (255,)
|
||
d.rectangle([0, n * 0.44, n, n * 0.60], fill=band)
|
||
mask = Image.new("L", (n, n), 0)
|
||
ImageDraw.Draw(mask).ellipse([0, 0, n - 1, n - 1], fill=255)
|
||
img.putalpha(mask)
|
||
return img.resize((size, size), Image.LANCZOS)
|
||
|
||
|
||
def main():
|
||
svg = open(SRC).read()
|
||
for size in LAUNCHER:
|
||
outdir = os.path.join(ROOT, "resources-launcher-%d" % size, "drawables")
|
||
os.makedirs(outdir, exist_ok=True)
|
||
launcher(size).save(os.path.join(outdir, "launcher_icon.png"))
|
||
with open(os.path.join(outdir, "drawables.xml"), "w") as f:
|
||
f.write('<drawables>\n'
|
||
' <bitmap id="LauncherIcon" filename="launcher_icon.png"/>\n'
|
||
'</drawables>\n')
|
||
print("wrote %s (%dx%d)" % (outdir, size, size))
|
||
cache = {}
|
||
for width, blend in sorted(WIDTHS.items()):
|
||
size = int(round(BOX * width / 500.0))
|
||
outdir = os.path.join(ROOT, "resources-icons-%d" % width, "drawables")
|
||
os.makedirs(outdir, exist_ok=True)
|
||
names = []
|
||
for name, (pid, cx, cy, flip) in ICONS.items():
|
||
key = (pid, flip)
|
||
if key not in cache:
|
||
p = subpaths(svg, pid)
|
||
cache[key] = flip_chevron(p) if flip else p
|
||
img = render(cache[key], size, cx, cy, blend)
|
||
img.save(os.path.join(outdir, "%s.png" % name))
|
||
names.append(name)
|
||
with open(os.path.join(outdir, "drawables.xml"), "w") as f:
|
||
f.write("<drawables>\n")
|
||
for n in names:
|
||
# packingFormat="png" keeps the alpha channel instead of
|
||
# collapsing the image to a palette; drawBitmap2(:tintColor)
|
||
# refuses a palettised source.
|
||
f.write(' <bitmap id="Icon%s" filename="%s.png" '
|
||
'packingFormat="png"/>\n' % (n.capitalize(), n))
|
||
f.write("</drawables>\n")
|
||
print("wrote %s (%dx%d, %s edges)"
|
||
% (outdir, size, size, "soft" if blend else "hard"))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|