Files
fenix8v3-watchface/tools/gen_themes.py
ericwyuan 398018b51e Rebuild the face against the design kit; make fields configurable
Vendor the watchface-kit into design/ and derive everything from it instead
of from hand-transcribed constants.

Geometry, from design/rebuild.py and the ink boxes of the traced SVG paths:
  - ring ticks are annular sectors (fillPolygon), not radial lines
  - top arc is 21 ticks at 6.5+4k, not 22 at 2+4k
  - exact radii, tick widths, anchor dots, band and colon rectangles
  - element positions taken from measured ink bounding boxes

Colour, generated by tools/gen_themes.py straight out of the comps:
  - per-tick colour tables (21+21) replace 9/7 interpolated stops
  - 11-stop vertical gradients on the time digits, drawn with clip banding
  - per-element accent / value colours and per-theme unlit tick colour

Data: the design's sunrise and hi/lo fields turn out to be available after
all -- Weather.getSunrise and getDailyForecast both exist in SDK 9.1.0 --
so both are restored, and heart rate is added alongside them.

Bugs found on the way:
  - Gregorian FORMAT_MEDIUM returns day_of_week/month as strings, so the
    date band crashed on every update (caught in the simulator)
  - Stats.battery is already 0-100, multiplying by 100 gave "4000%"
  - moveBarLevel is an integer level 0..5, not a 0..1 ratio, so the move
    bar arc was all-or-nothing

Also: all four field slots are configurable with labels that follow the
selection, temperature unit is a setting, on-face wording moved into
resources with a Simplified Chinese variant, and 24h/distance/temperature
now follow the watch. Fonts are chosen by measured cap height rather than
by ascent.

Verified in the Connect IQ simulator on fenix847mm and by compiling all
17 target devices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 22:04:27 +08:00

200 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""Generate source/Themes.mc from the design kit in design/.
Ground truth is the seven watchface-NN-*.svg files (produced by design/rebuild.py
from the original raster comps). Every colour written to Themes.mc is measured
from those files -- nothing here is hand-picked.
python3 tools/gen_themes.py
Extracted per theme:
ringTop[21] lit colour of each top tick, index 0 = 6.5 deg .. 20 = 86.5 deg
ringBottom[21] lit colour of each bottom tick, index 0 = 6.0 deg .. 20 = 66.0 deg
tickOff colour of an unlit tick
anchorDot the four dots between the arcs
bandFill/Text date band background / foreground
colon the two colon blocks
hours[11] vertical gradient ramp of the hour digits, top -> bottom
minutes[11] ditto for the minute digits
accent icons and DST/STEP labels
textPrimary complication and bottom-row values
Only 14 of the 21 bottom ticks are lit in any comp, so indices 14..20 of
ringBottom are extrapolated (least-squares fit over the last 8 measured ticks).
Those ticks only appear once a metric passes ~67%.
"""
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
DESIGN = os.path.join(ROOT, "design")
OUT = os.path.join(ROOT, "source", "Themes.mc")
ORDER = ["01", "02", "03", "04", "05", "06", "07"]
N_TICK = 21
BOT_MEASURED = 14 # lit ticks in the bottom-left arc of every comp
FIT_WINDOW = 8 # ticks used for the extrapolation fit
def rgb(h):
h = h.lstrip("#")
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
def hexs(c):
return "0x%02X%02X%02X" % tuple(max(0, min(255, int(round(v)))) for v in c)
def median(colours):
cols = [rgb(c) for c in colours]
return tuple(sorted(c[i] for c in cols)[len(cols) // 2] for i in range(3))
def group_colours(svg, gid):
m = re.search(r'<g id="%s">(.*?)</g>' % gid, svg, re.S)
if m is None:
raise SystemExit("missing group %s" % gid)
return re.findall(r'fill="(#[0-9A-Fa-f]{6})"', m.group(1))
def path_fill(svg, pid):
m = re.search(r'<path id="%s"[^>]*fill="(#[0-9A-Fa-f]{6})"' % pid, svg)
return m.group(1) if m else None
def gradient_ramp(svg, gid):
"""The 11 usable stops of a digit gradient.
rebuild.py samples the glyph row by row, so the first and last stop are the
antialiased edge rows and read far too dark. Drop them.
"""
m = re.search(r'<linearGradient id="%s".*?>(.*?)</linearGradient>' % gid, svg, re.S)
stops = re.findall(r'stop-color="(#[0-9A-Fa-f]{6})"', m.group(1))
return stops[1:-1]
def extrapolate(ramp):
"""Extend a measured colour ramp to N_TICK entries by linear fit."""
known = [rgb(c) for c in ramp]
n = len(known)
if n >= N_TICK:
return known[:N_TICK]
xs = list(range(n - FIT_WINDOW, n))
out = list(known)
for i in range(n, N_TICK):
nxt = []
for ch in range(3):
ys = [known[x][ch] for x in xs]
mx = sum(xs) / float(len(xs))
my = sum(ys) / float(len(ys))
den = sum((x - mx) ** 2 for x in xs)
slope = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / den if den else 0.0
nxt.append(max(0, min(255, my + slope * (i - mx))))
out.append(tuple(nxt))
return out
def read_theme(tid):
hits = [f for f in os.listdir(DESIGN)
if f.startswith("watchface-%s-" % tid) and f.endswith(".svg")]
if not hits:
raise SystemExit("no SVG for theme %s in %s" % (tid, DESIGN))
path = os.path.join(DESIGN, hits[0])
svg = open(path).read()
name = re.search(r"<title>Watch face \d+ (\w+)</title>", svg).group(1)
top_r = group_colours(svg, "ring-top-right") # fully lit in every comp
top_l = group_colours(svg, "ring-top-left") # lit from the far end
bot_l = group_colours(svg, "ring-bottom-left") # lit from index 0
# An unlit tick is whatever colour the dim end of the top-left arc has.
tick_off = median(top_l[:8])
ring_top = [rgb(c) for c in top_r]
ring_bottom = extrapolate(bot_l[:BOT_MEASURED])
band = re.search(r'<g id="date-band">\s*<rect[^>]*fill="(#[0-9A-Fa-f]{6})"', svg).group(1)
dot = re.search(r'<g id="ring-anchors">\s*<circle[^>]*fill="(#[0-9A-Fa-f]{6})"', svg).group(1)
colon = re.search(r'<g id="time-colon"><rect[^>]*fill="(#[0-9A-Fa-f]{6})"', svg).group(1)
accent = median([path_fill(svg, p) for p in
("battery-icon", "sunrise-icon", "weather-icon",
"dist-label", "steps-label")])
primary = median([path_fill(svg, p) for p in
("battery-value", "sunrise-value", "weather-value",
"dist-value", "steps-value")])
return {
"id": tid,
"name": name,
"ringTop": ring_top,
"ringBottom": ring_bottom,
"tickOff": tick_off,
"anchorDot": rgb(dot),
"bandFill": rgb(band),
"bandText": rgb(path_fill(svg, "bt-icon")),
"colon": rgb(colon),
"hours": [rgb(c) for c in gradient_ramp(svg, "gHours")],
"minutes": [rgb(c) for c in gradient_ramp(svg, "gMinutes")],
"accent": accent,
"textPrimary": primary,
}
def fmt_list(colours, indent):
pad = " " * indent
out, line = [], pad + "["
for i, c in enumerate(colours):
piece = hexs(c) + ("," if i < len(colours) - 1 else "]")
if len(line) + len(piece) > 108:
out.append(line)
line = pad + " "
line += piece
out.append(line)
return "\n".join(out)
def main():
themes = [read_theme(t) for t in ORDER]
L = []
L.append("// AUTO-GENERATED by tools/gen_themes.py from design/watchface-*.svg.")
L.append("// Do not edit by hand -- change the design kit and re-run the generator.")
L.append("//")
L.append("// ringTop/ringBottom lit colour per tick, index 0 nearest 12 / 6 o'clock")
L.append("// hours/minutes 11-stop vertical gradient of the time digits, top -> bottom")
L.append("import Toybox.Lang;")
L.append("")
L.append("module Themes {")
L.append("")
L.append(" const NAMES = [")
for t in themes:
L.append(' "%s",' % t["name"])
L.append(" ];")
L.append("")
for key in ("ringTop", "ringBottom", "hours", "minutes"):
L.append(" const %s = [" % key.upper())
for t in themes:
L.append(" // %s %s" % (t["id"], t["name"]))
L.append(fmt_list(t[key], 8) + ",")
L.append(" ];")
L.append("")
for key in ("tickOff", "anchorDot", "bandFill", "bandText",
"colon", "accent", "textPrimary"):
vals = ", ".join(hexs(t[key]) for t in themes)
L.append(" const %s = [%s];" % (key.upper(), vals))
L.append("}")
text = "\n".join(L) + "\n"
open(OUT, "w").write(text)
print("wrote %s (%d themes, %d bytes)" % (OUT, len(themes), len(text)))
for t in themes:
print(" %s %-8s accent=%s text=%s tickOff=%s" %
(t["id"], t["name"], hexs(t["accent"]),
hexs(t["textPrimary"]), hexs(t["tickOff"])))
if __name__ == "__main__":
sys.exit(main())