Files
fenix8v3-watchface/tools/gen_icons.py
ericwyuan d457f8244e Add the on-device data field catalogue, always-on mode, and real icons
Fields
  75 selectable fields across date/time, activity, body, system,
  environment, weather and custom text, numbered to match
  watchface.io/docs/datafields so the two catalogues line up.  All four
  slots (both top, both bottom) pick from the same table and the bottom
  captions follow the selection.  tools/gen_fields.py owns the table and
  emits FieldTable.mc, settings.xml and both languages' strings, so those
  cannot drift apart.

  Fields.Ctx fetches each API at most once per draw, and only if some slot
  asks -- the forecast, user profile and sensor history lookups are lazy.

  The rest of that catalogue is deliberately absent: OpenWeatherMap and
  StormGlass need network calls and user API keys, the per-sport weekly and
  28-day aggregates come from a phone-side Garmin Connect integration rather
  than any on-device API, and the app complications need those apps
  installed.  README section 7 lists what was left out and why.

Permissions
  UserProfile and SensorHistory are now declared; without them resting heart
  rate, VO2 max, pulse ox and body battery throw at runtime.  Sensor turns
  out to be rejected outright for type="watchface", so altitude and pressure
  read from Activity.Info instead.

Always-on display
  AMOLED devices need the lit-pixel count cut while asleep.  drawAmbient
  drops the band fill and the unlit ticks, dims what remains, hides the
  complications, and walks the layout within +/-4 px per minute so nothing
  burns in.  Gated on requiresBurnInProtection, so MIP devices are untouched.

Icons
  The comps' sunrise and weather icons have two- and three-pixel features.
  Rebuilding them from circles and lines looked right at 454 px and turned to
  mush at 280.  They are now rasterised from the design paths once per screen
  width and tinted with drawBitmap2(:tintColor) -- which needs
  packingFormat="png", since a palettised source is refused at runtime.
  Launcher icons are generated at the five sizes the devices ask for.

All 17 devices build; checked on fenix847mm and enduro3 in the simulator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 23:53:18 +08:00

200 lines
7.5 KiB
Python

#!/usr/bin/env python3
"""Rasterise the design's icon paths into tintable bitmap resources.
The sunrise and weather icons in design/*.svg are traced outlines whose finest
features are two or three design pixels across. Redrawing them from circles
and lines works at 454 px and falls apart at 280 px, where those features land
under one device pixel. Rendering the real paths once per screen size and
blitting them with drawBitmap2(:tintColor) keeps them exact everywhere.
python3 tools/gen_icons.py
Writes resources-icons-<W>/drawables/{sunrise,sunset,weather}.png plus the
matching drawables.xml. White pixels, alpha coverage; the theme's accent
colour is applied at draw time via :tintColor.
MIP devices have no alpha blending, so for those widths the coverage mask is
thresholded to hard edges rather than left antialiased -- a soft edge there
would be quantised to something muddier than a clean one.
"""
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()