#!/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())