#!/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'(.*?)' % 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']*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'(.*?)' % 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"Watch face \d+ (\w+)", 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'\s*]*fill="(#[0-9A-Fa-f]{6})"', svg).group(1)
dot = re.search(r'\s*]*fill="(#[0-9A-Fa-f]{6})"', svg).group(1)
colon = re.search(r']*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())