#!/usr/bin/env python3
"""从 design/ 的设计稿生成 source/Themes.mc。
事实来源是七个 watchface-NN-*.svg(原作者用 design/rebuild.py 从位图逆向出来的)。
写进 Themes.mc 的每一个颜色都是从这些文件里**量出来的**,没有一个是手挑的。
python3 tools/gen_themes.py
每套主题提取出:
ringTop[21] 顶弧每根刻度点亮时的颜色,下标 0 = 6.5°,20 = 86.5°
ringBottom[21] 底弧每根刻度点亮时的颜色,下标 0 = 6.0°,20 = 66.0°
tickOff 未点亮刻度的颜色
anchorDot 四段弧之间的锚点圆点
bandFill/Text 日期带的底色 / 文字色
colon 两块冒号
hours[11] 小时数字的竖向渐变,从上到下
minutes[11] 分钟数字的竖向渐变
accent 图标与 DST/STEP 标签
textPrimary 各数据位与底部行的数值
⚠️ 已知近似:设计稿里底弧最多只点亮 14/21 根,所以 ringBottom 的第 15–21 项
无法实测,由最后 8 根做最小二乘线性外推得到。只有当指标超过约 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("// ⚠️ 本文件由 tools/gen_themes.py 从 design/watchface-*.svg 自动生成,请勿手改。")
L.append("// 要改配色就改设计稿里的 SVG,然后重新跑一遍生成器。")
L.append("//")
L.append("// 七套主题的全部颜色数据。各数组的下标就是主题序号(0=Ember … 6=Kelp)。")
L.append("//")
L.append("// RINGTOP/RINGBOTTOM 每根刻度点亮时的颜色,各 21 项。")
L.append("// 下标 0 是最靠近 12 点 / 6 点的那根。")
L.append("// 这是从设计稿里**逐根取样**的实测值,不是插值近似。")
L.append("// HOURS/MINUTES 时间数字的竖向渐变,各 11 个停止点,从上到下。")
L.append("// TICKOFF 未点亮刻度的颜色")
L.append("// ANCHORDOT 四个锚点圆点")
L.append("// BANDFILL/BANDTEXT 日期带的底色 / 文字色")
L.append("// ACCENT 图标与底部标签")
L.append("// TEXTPRIMARY 各项数值")
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())