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>
This commit is contained in:
2026-09-09 22:04:27 +08:00
parent 553c55026a
commit 398018b51e
22 changed files with 3334 additions and 1012 deletions

329
tools/gen_preview_svg.py Normal file
View File

@@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""Render preview-themes.svg and preview.html by replaying the face's draw calls.
Colours are parsed straight out of source/Themes.mc and the geometry constants
are the same literals as source/Fenix8V3View.mc, so the preview cannot drift
from the firmware. Text is drawn with a web font rather than Garmin's Roboto
Condensed / Bionic, so judge geometry and colour here, not glyph shapes.
python3 tools/gen_preview_svg.py
"""
import math
import os
import re
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "preview-themes.svg")
OUT_HTML = os.path.join(ROOT, "preview.html")
# ---------------------------------------------------------------- geometry --
R_IN, R_OUT = 231.09, 249.10
TOP_N, TOP_PH, TOP_PITCH, TOP_HW = 21, 6.500, 4.000, 1.554
BOT_N, BOT_PH, BOT_PITCH, BOT_HW = 21, 6.005, 3.000, 1.043
DOT_RAD, DOT_R = 240.10, 9.00
DOT_ANG = [0.0, 93.15, 180.0, 266.85]
BATT_X, BATT_Y, BATT_W, BATT_H = 192.70, 44.02, 26.20, 17.81
BATT_WALL, BATT_TW, BATT_TH = 2.20, 2.53, 9.20
BATT_VX, BATT_CY = 232.00, 53.20
LEFT_X, RIGHT_X, ICON_CY, VALUE_CY = 157.00, 343.70, 90.00, 131.06
TIME_CY, HOURS_R, MINUTES_L = 221.10, 211.40, 276.02
COLON_X, COLON_W, COLON_H, COLON_Y1, COLON_Y2 = 237.88, 24.24, 21.30, 183.68, 234.57
BAND_Y, BAND_H, BAND_CY = 286.30, 44.19, 308.10
BT_CX, BT_CY, BT_HW, BT_HH = 36.88, 306.99, 8.89, 12.92
DOW_CX, MD_CX, AMPM_R = 178.05, 298.28, 465.89
BOT_VAL_CY, BOT_LAB_CY, DIST_R, STEP_L = 366.76, 410.88, 234.30, 267.40
CAP_BAND, CAP_COMP, CAP_TIME = 25.34, 31.50, 98.70
DOW = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]
MON = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN",
"JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]
# ------------------------------------------------------------ Themes.mc in --
def load_themes():
src = open(os.path.join(ROOT, "source", "Themes.mc")).read()
def const(name):
m = re.search(r"const %s = \[(.*?)\n \];" % name, src, re.S)
if m: # nested: one row per theme
rows, cur = [], []
for line in m.group(1).split("\n"):
if line.strip().startswith("//"):
if cur:
rows.append(cur)
cur = []
continue
cur += [int(v, 16) for v in re.findall(r"0x([0-9A-Fa-f]{6})", line)]
if cur:
rows.append(cur)
return rows
m = re.search(r"const %s = \[(.*?)\];" % name, src)
return [int(v, 16) for v in re.findall(r"0x([0-9A-Fa-f]{6})", m.group(1))]
names = re.findall(r'^\s+"(\w+)",', src, re.M)
return names, {k: const(k) for k in
("RINGTOP", "RINGBOTTOM", "HOURS", "MINUTES", "TICKOFF",
"ANCHORDOT", "BANDFILL", "BANDTEXT", "COLON",
"ACCENT", "TEXTPRIMARY")}
def hx(c):
return "#%06X" % (c & 0xFFFFFF)
def polar(ang, r):
t = math.radians(ang)
return 250.0 + math.sin(t) * r, 250.0 - math.cos(t) * r
# ------------------------------------------------------------------ pieces --
def ring(th, T, p):
out = []
top, bot, off = T["RINGTOP"][th], T["RINGBOTTOM"][th], T["TICKOFF"][th]
def arc(ramp, lit, ph, pitch, hw, n, base, mirror, from_end):
for i in range(n):
a = ph + pitch * i
on = (i >= n - lit) if from_end else (i < lit)
ang = base + (-a if mirror else a)
c = [polar(ang - hw, R_OUT), polar(ang + hw, R_OUT),
polar(ang + hw, R_IN), polar(ang - hw, R_IN)]
out.append('<polygon points="%s" fill="%s"/>' % (
" ".join("%.2f,%.2f" % q for q in c), hx(ramp[i] if on else off)))
arc(top, round(p[0] * TOP_N), TOP_PH, TOP_PITCH, TOP_HW, TOP_N, 0.0, False, True)
arc(top, round(p[1] * TOP_N), TOP_PH, TOP_PITCH, TOP_HW, TOP_N, 0.0, True, True)
arc(bot, round(p[3] * BOT_N), BOT_PH, BOT_PITCH, BOT_HW, BOT_N, 180.0, False, False)
arc(bot, round(p[2] * BOT_N), BOT_PH, BOT_PITCH, BOT_HW, BOT_N, 180.0, True, False)
for a in DOT_ANG:
x, y = polar(a, DOT_RAD)
out.append('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="%s"/>'
% (x, y, DOT_R, hx(T["ANCHORDOT"][th])))
return "".join(out)
def txt(x, cy, cap, s, anchor, col, weight=700):
return ('<text x="%.2f" y="%.2f" font-size="%.2f" font-weight="%d" fill="%s" '
'text-anchor="%s" font-family="Roboto Condensed,Arial Narrow,sans-serif" '
'dominant-baseline="central">%s</text>'
% (x, cy, cap / 0.72, weight, col, anchor, s))
def grad_txt(gid, x, cy, cap, s, anchor, ramp):
y0, y1 = cy - cap / 2, cy + cap / 2
n = len(ramp)
stops = "".join('<stop offset="%.3f" stop-color="%s"/>' % ((i + 0.5) / n, hx(c))
for i, c in enumerate(ramp))
d = ('<linearGradient id="%s" x1="0" y1="%.2f" x2="0" y2="%.2f" '
'gradientUnits="userSpaceOnUse">%s</linearGradient>' % (gid, y0, y1, stops))
return d, txt(x, cy, cap, s, anchor, "url(#%s)" % gid)
def battery(x, cy, k, col, level):
w, h, wall = BATT_W * k, BATT_H * k, BATT_WALL * k
y = cy - h / 2
o = ['<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" rx="%.2f" fill="%s"/>'
% (x, y, w, h, 3.4 * k, col),
'<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"/>'
% (x + w, cy - BATT_TH * k / 2, BATT_TW * k, BATT_TH * k, col),
'<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="#000"/>'
% (x + wall, y + wall, w - 2 * wall, h - 2 * wall)]
if level > 0:
o.append('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"/>'
% (x + wall, y + wall, (w - 2 * wall) * level, h - 2 * wall, col))
return "".join(o)
def icon_sun(cx, cy, k, col, direction=1):
ox, oy = cx - 156.32 * k, cy - 90.34 * k
rx, ry = 7.45 * k, 8.93 * k
bx, by = ox + 156.39 * k, oy + 94.02 * k
pts = [(bx + math.cos(math.radians(180 - 15 * i)) * rx,
by - math.sin(math.radians(180 - 15 * i)) * ry) for i in range(13)]
o = ['<polygon points="%s" fill="%s"/>'
% (" ".join("%.2f,%.2f" % q for q in pts), col)]
def ln(x1, y1, x2, y2, w):
return ('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" '
'stroke-width="%.2f" stroke-linecap="round"/>'
% (x1, y1, x2, y2, col, w))
w1 = 2.96 * k
o += [ln(ox + 156.39 * k, oy + 79.4 * k, ox + 156.39 * k, oy + 81.1 * k, w1),
ln(ox + 148.2 * k, oy + 83.3 * k, ox + 146.5 * k, oy + 84.5 * k, w1),
ln(ox + 164.6 * k, oy + 84.5 * k, ox + 166.3 * k, oy + 83.3 * k, w1),
ln(ox + 142.6 * k, oy + 92.5 * k, ox + 144.7 * k, oy + 92.5 * k, w1),
ln(ox + 168.0 * k, oy + 92.5 * k, ox + 170.1 * k, oy + 92.5 * k, w1)]
w2, hy = 2.6 * k, oy + 98.45 * k
o += [ln(ox + 142.3 * k, hy, ox + 170.3 * k, hy, w2),
ln(ox + 152.6 * k, hy, ox + 156.3 * k, hy + direction * 3.05 * k, w2),
ln(ox + 156.3 * k, hy + direction * 3.05 * k, ox + 160.0 * k, hy, w2)]
return "".join(o)
def icon_cloud(cx, cy, k, col):
ox, oy = cx - 343.55 * k, cy - 89.25 * k
def ln(x1, y1, x2, y2, w):
return ('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" '
'stroke-width="%.2f" stroke-linecap="round"/>'
% (x1, y1, x2, y2, col, w))
def body(grow, fill):
return ('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="%s"/>'
'<circle cx="%.2f" cy="%.2f" r="%.2f" fill="%s"/>'
'<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" rx="%.2f" fill="%s"/>'
% (ox + 336.6 * k, oy + 94.4 * k, 8.7 * k + grow, fill,
ox + 346.4 * k, oy + 96.9 * k, 6.3 * k + grow, fill,
ox + 328.0 * k - grow, oy + 95.5 * k - grow,
24.6 * k + 2 * grow, 7.75 * k + grow, 3.4 * k, fill))
o = ['<circle cx="%.2f" cy="%.2f" r="%.2f" fill="%s"/>'
% (ox + 347.0 * k, oy + 88.9 * k, 7.8 * k, col)]
o += [ln(ox + 346.2 * k, oy + 76.0 * k, ox + 346.2 * k, oy + 78.7 * k, 2.3 * k),
ln(ox + 354.9 * k, oy + 87.7 * k, ox + 357.5 * k, oy + 87.7 * k, 2.3 * k),
ln(ox + 339.5 * k, oy + 79.5 * k, ox + 338.1 * k, oy + 80.9 * k, 2.7 * k),
ln(ox + 353.5 * k, oy + 80.9 * k, ox + 354.6 * k, oy + 79.8 * k, 2.7 * k)]
o.append(body(1.7 * k, "#000000"))
o.append(body(0.0, col))
return "".join(o)
def bluetooth(col):
l, r = BT_CX - BT_HW, BT_CX + BT_HW
t, b = BT_CY - BT_HH, BT_CY + BT_HH
qt, qb = BT_CY - BT_HH / 2, BT_CY + BT_HH / 2
pts = [(l, qt), (r, qb), (BT_CX, b), (BT_CX, t), (r, qt), (l, qb)]
return ('<polyline points="%s" fill="none" stroke="%s" stroke-width="3.2" '
'stroke-linecap="round" stroke-linejoin="round"/>'
% (" ".join("%.2f,%.2f" % q for q in pts), col))
def face(th, names, T, data):
accent, primary = hx(T["ACCENT"][th]), hx(T["TEXTPRIMARY"][th])
defs, s = [], ['<rect width="500" height="500" fill="#000"/>',
ring(th, T, data["ring"])]
s.append(battery(BATT_X, BATT_CY, 1.0, accent, data["batt"]))
s.append(txt(BATT_VX, BATT_CY, CAP_BAND, "%d%%" % round(data["batt"] * 100),
"start", primary))
s.append(icon_sun(LEFT_X, ICON_CY, 1.0, accent))
s.append(txt(LEFT_X, VALUE_CY, CAP_COMP, data["sunrise"], "middle", primary))
s.append(icon_cloud(RIGHT_X, ICON_CY, 1.0, accent))
s.append(txt(RIGHT_X, VALUE_CY, CAP_COMP, data["hilo"], "middle", primary))
d, t = grad_txt("gH%d" % th, HOURS_R, TIME_CY, CAP_TIME, data["hh"], "end",
T["HOURS"][th])
defs.append(d)
s.append(t)
d, t = grad_txt("gM%d" % th, MINUTES_L, TIME_CY, CAP_TIME, data["mm"], "start",
T["MINUTES"][th])
defs.append(d)
s.append(t)
for y in (COLON_Y1, COLON_Y2):
s.append('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"/>'
% (COLON_X, y, COLON_W, COLON_H, hx(T["COLON"][th])))
band, btxt = hx(T["BANDFILL"][th]), hx(T["BANDTEXT"][th])
s.append('<rect x="0" y="%.2f" width="500" height="%.2f" fill="%s"/>'
% (BAND_Y, BAND_H, band))
s.append(bluetooth(btxt))
s.append(txt(DOW_CX, BAND_CY, CAP_BAND, data["dow"], "middle", btxt))
s.append(txt(MD_CX, BAND_CY, CAP_BAND, data["md"], "middle", btxt))
s.append(txt(AMPM_R, BAND_CY, CAP_BAND, data["ampm"], "end", btxt))
s.append(txt(DIST_R, BOT_VAL_CY, CAP_COMP, data["dist"], "end", primary))
s.append(txt(DIST_R, BOT_LAB_CY, CAP_COMP, "DST", "end", accent))
s.append(txt(STEP_L, BOT_VAL_CY, CAP_COMP, data["steps"], "start", primary))
s.append(txt(STEP_L, BOT_LAB_CY, CAP_COMP, "STEP", "start", accent))
return "".join(defs), "".join(s)
HTML = """<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fenix 8 V3 - 表盘预览</title>
<style>
:root{--bg:#0a0a0a;--ink:#e9e6e0;--dim:#8d8a84;--line:#26261f}
body{margin:0;background:var(--bg);color:var(--ink);
font:14px/1.6 -apple-system,"PingFang SC",Helvetica,Arial,sans-serif}
.wrap{max-width:760px;margin:0 auto;padding:32px 20px 64px}
h1{font-size:20px;margin:0 0 4px}
p.lede{color:var(--dim);margin:0 0 24px}
.stage{background:#000;border-radius:50%%;width:420px;height:420px;
margin:0 auto 24px;overflow:hidden}
svg.f{display:none;width:420px;height:420px}
svg.f.on{display:block}
.themes{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;max-width:420px;margin:0 auto}
button{cursor:pointer;border:1px solid var(--line);background:#1c1c19;color:var(--ink);
border-radius:8px;padding:9px 4px;font-size:12px}
button.on{background:#FEAA00;color:#000;border-color:#FEAA00;font-weight:600}
footer{color:var(--dim);font-size:12px;margin-top:28px;text-align:center}
</style></head><body><div class="wrap">
<h1>Fenix 8 V3 表盘预览</h1>
<p class="lede">由 <code>tools/gen_preview_svg.py</code> 从 <code>source/Themes.mc</code>
生成,几何常量与 <code>source/Fenix8V3View.mc</code> 一致。文字用网页字体渲染,
真机为 Garmin 内置 Roboto / Bionic 字体,字形会有差异。</p>
<div class="stage">%(faces)s</div>
<div class="themes">%(buttons)s</div>
<footer>七套主题 · 进度环状态取自设计稿:右上 21/21、左上 10/21、右下 9/21、左下 14/21</footer>
</div>
<script>
var faces=document.querySelectorAll("svg.f"), btns=document.querySelectorAll("button");
faces[0].classList.add("on");
btns.forEach(function(b){b.onclick=function(){
var i=+b.dataset.i;
faces.forEach(function(f){f.classList.toggle("on",+f.dataset.i===i)});
btns.forEach(function(o){o.classList.toggle("on",o===b)});
}});
</script></body></html>
"""
def main():
names, T = load_themes()
# The state the design comps are drawn in, so the preview is comparable.
data = {"ring": [1.0, 10 / 21.0, 9 / 21.0, 14 / 21.0],
"batt": 0.40, "sunrise": "6:34", "hilo": "63°/52°",
"hh": "11", "mm": "22", "dow": "MON", "md": "MAY 26", "ampm": "AM",
"dist": "0.7", "steps": "1337"}
cell, gap = 230, 24
n = len(names)
W, H = n * cell + (n + 1) * gap, cell + 70
parts = ['<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" '
'viewBox="0 0 %d %d">' % (W, H, W, H),
'<rect width="%d" height="%d" fill="#0a0a0a"/>' % (W, H)]
for i, name in enumerate(names):
ox, oy, sc = gap + i * (cell + gap), 24, cell / 500.0
defs, body = face(i, names, T, data)
parts.append('<defs><clipPath id="c%d"><circle cx="%.1f" cy="%.1f" r="%.1f"/>'
'</clipPath>%s</defs>'
% (i, ox + cell / 2, oy + cell / 2, cell / 2 - 2, defs))
parts.append('<g clip-path="url(#c%d)"><g transform="translate(%d,%d) '
'scale(%.4f)">%s</g></g>' % (i, ox, oy, sc, body))
parts.append('<text x="%.1f" y="%.1f" fill="#cfccc4" font-size="15" '
'font-family="sans-serif" text-anchor="middle">%d · %s</text>'
% (ox + cell / 2, oy + cell + 22, i + 1, name))
parts.append("</svg>")
svg = "\n".join(parts)
open(OUT, "w").write(svg)
print("wrote %s (%d x %d, %d bytes)" % (OUT, W, H, len(svg)))
# Same faces again, one per theme, as a switchable page.
big = []
for i, name in enumerate(names):
defs, body = face(i, names, T, data)
big.append('<svg class="f" data-i="%d" viewBox="0 0 500 500" '
'xmlns="http://www.w3.org/2000/svg"><defs>%s</defs>%s</svg>'
% (i, defs, body))
btns = "".join('<button data-i="%d"%s>%d · %s</button>'
% (i, ' class="on"' if i == 0 else "", i + 1, n)
for i, n in enumerate(names))
open(OUT_HTML, "w").write(HTML % {"faces": "".join(big), "buttons": btns,
"n": len(names)})
print("wrote %s (%d themes)" % (OUT_HTML, len(names)))
if __name__ == "__main__":
main()

199
tools/gen_themes.py Normal file
View File

@@ -0,0 +1,199 @@
#!/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())