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>
200 lines
7.1 KiB
Python
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())
|