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>
330 lines
15 KiB
Python
330 lines
15 KiB
Python
#!/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()
|