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