Files
fenix8v3-watchface/tools/gen_preview_svg.py
ericwyuan 4d96ab0912 Update watch face: modular refactor + OWM/Solar/background + codegen tools
- Split render into FieldTable/Fields/Icons/Layout/Settings modules
- Add Fenix8V3Background, Owm (weather), Solar modules
- Add tools/ generators (gen_fields/gen_icons/gen_themes/gen_preview_svg)
- Tune README/manifest/strings/settings
2026-09-10 07:10:55 +08:00

331 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""重放表盘自己的绘制逻辑,产出 preview-themes.svg 和 preview.html。
颜色直接解析自 source/Themes.mc几何常量与 source/Fenix8V3View.mc /
source/Layout.mc 保持同一套字面值,所以预览不会和固件漂移。
⚠️ 文字用的是网页字体,真机是 Garmin 内置的 Roboto / Bionic字形会有差异。
**几何和配色可信,字形不可信** —— 判断字形要看模拟器截图。
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()