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
This commit is contained in:
2026-09-10 07:10:55 +08:00
parent 52346bdd09
commit 4d96ab0912
23 changed files with 1549 additions and 344 deletions

View File

@@ -2,52 +2,57 @@ import Toybox.Lang;
import Toybox.Graphics;
import Toybox.Math;
// Geometry and text plumbing for the face.
// ============================================================================
// 布局与文字绘制
// ----------------------------------------------------------------------------
// 本文件里所有的数字都是**设计稿 500×500 画布上的坐标**,全部来自
// design/watchface-*.svg 的实测:
// - 进度环的几何参数取自 design/rebuild.py原作者从位图逆向出的常量
// - 各元素位置取自 SVG 描边路径的墨迹包围盒,不是目测的
//
// Every literal here is a coordinate on the design's 500x500 canvas, measured
// from design/watchface-*.svg -- ring numbers come from design/rebuild.py, the
// element positions are the ink bounding boxes of the traced SVG paths. At
// draw time everything is multiplied by s = screenWidth / 500, so one set of
// numbers serves screens from 240 px (fenix 7S) to 454 px (venu 3).
// 绘制时统一乘以 s = 屏宽 / 500所以同一套数字可以覆盖
// 240pxfenix 7S到 454pxvenu 3全部 17 款设备。
//
// Call init() from the view's onLayout before anything else touches this.
// ⚠️ 使用前必须先由 view onLayout 调用一次 init()。
// ============================================================================
module Layout {
// ---------------------------------------------------------------- ring
// Ticks are annular sectors of constant angular width, not radial lines,
// which is why they are drawn as four-point polygons rather than strokes.
const R_IN = 231.09; // inner edge of the tick band
const R_OUT = 249.10; // outer edge
const TOP_N = 21; // ticks per top arc
const TOP_PH = 6.500; // angle of tick 0, degrees clockwise from 12
// ------------------------------- 进度环 -------------------------------
// 刻度是**等角宽的环形扇区**,不是从圆心发射的线段 —— 所以画的时候用四点
// 多边形填充,而不是 drawLine 描边。这点很关键:用线段画会导致刻度在内圈
// 挤、外圈疏,与设计稿对不上。
const R_IN = 231.09; // 刻度带内边缘半径
const R_OUT = 249.10; // 刻度带外边缘半径
const TOP_N = 21; // 每条顶弧的刻度数
const TOP_PH = 6.500; // 第 0 根刻度的角度(自 12 点起顺时针,度)
const TOP_PITCH = 4.000;
const TOP_HW = 1.554; // half of the 3.108 deg tick width
const BOT_N = 21; // ticks per bottom arc
const BOT_PH = 6.005; // measured clockwise from 6 o'clock
const TOP_HW = 1.554; // 刻度角宽 3.108° 的一半
const BOT_N = 21; // 每条底弧的刻度数
const BOT_PH = 6.005; // 底弧角度自 6 点起量
const BOT_PITCH = 3.000;
const BOT_HW = 1.043;
const DOT_RAD = 240.10; // anchor dots sit on the band's mid-radius
const DOT_RAD = 240.10; // 四个锚点圆点所在半径(正好是刻度带中线)
const DOT_R = 9.00;
const DOT_ANG = [0.0, 93.15, 180.0, 266.85];
// ------------------------------------------------------------- elements
const BATT_X = 192.70; // battery body, left edge
const BATT_W = 26.20; // body only; the terminal sits to its right
// ------------------------------- 各元素 -------------------------------
const BATT_X = 192.70; // 电池外框左边缘
const BATT_W = 26.20; // 只是外框宽度,右侧的触点另算
const BATT_H = 17.81;
const BATT_WALL = 2.20; // outline thickness
const BATT_TW = 2.53; // terminal
const BATT_WALL = 2.20; // 外框线宽
const BATT_TW = 2.53; // 右侧触点宽
const BATT_TH = 9.20;
const BATT_VX = 232.00; // "40%" left edge
const BATT_VX = 232.00; // 百分比文字左边缘
const BATT_CY = 53.20;
const LEFT_X = 157.00; // top data slots, centred
const LEFT_X = 157.00; // 顶部两个数据位的中心 x居中对齐
const RIGHT_X = 343.70;
const ICON_CY = 89.50; // matches the icon bitmaps' own centre
const ICON_CY = 89.50; // 与图标位图自身的中心对齐,别随意改
const VALUE_CY = 131.06;
const TIME_CY = 221.10;
const HOURS_R = 211.40; // hour digits, right edge
const MINUTES_L = 276.02; // minute digits, left edge
const HOURS_R = 211.40; // 小时数字右边缘(右对齐基准)
const MINUTES_L = 276.02; // 分钟数字左边缘(左对齐基准)
const COLON_X = 237.88;
const COLON_W = 24.24;
const COLON_H = 21.30;
@@ -57,7 +62,7 @@ module Layout {
const BAND_Y = 286.30;
const BAND_H = 44.19;
const BAND_CY = 308.10;
const BT_CX = 36.88; // bluetooth rune
const BT_CX = 36.88; // 蓝牙符号中心
const BT_CY = 306.99;
const BT_HW = 8.89;
const BT_HH = 12.92;
@@ -67,30 +72,37 @@ module Layout {
const BOT_VAL_CY = 366.76;
const BOT_LAB_CY = 410.88;
const DIST_R = 234.30; // right edge of both left-hand bottom lines
const STEP_L = 267.40; // left edge of both right-hand bottom lines
const DIST_R = 234.30; // 左下格(数值+标签)的右对齐基准
const STEP_L = 267.40; // 右下格(数值+标签)的左对齐基准
// Cap heights of the design's three text sizes.
const CAP_BAND = 25.34; // date band, battery percentage
const CAP_COMP = 31.50; // data slots, bottom row
const CAP_TIME = 98.70; // time digits
// 设计稿里三档文字的**字面高度**cap height大写字母/数字的高度,
// 不含上下留白)。字号选择就是按这个来匹配的。
const CAP_BAND = 25.34; // 日期带、电量百分比
const CAP_COMP = 31.50; // 四个数据位、底部行
const CAP_TIME = 98.70; // 时间大数字
// How much room a value may take before it is stepped down a size. The
// top slots are centred, so this is the full width they may span; the
// bottom pair are edge-aligned and share the middle of the dial.
// 数值最多占多宽,超了就降一档字号。
// 顶部两格是居中对齐,这个值是它能横跨的总宽度;
// 底部两格是边对齐,各占表盘中间往外的一半。
// 有了这个,用户就算把 6 个汉字的长日期放进底部格也不会压到进度环上。
const TOP_MAX_W = 185.0;
const BOT_MAX_W = 150.0;
// Connect IQ exposes ascent and descent but not cap height, and the ascent
// reserves room for diacritics that no digit or capital ever uses. These
// two ratios were measured by rendering into the simulator and reading the
// ink back off the screenshot (fenix847mm: FONT_XTINY ascent 29 -> caps
// 22 px; FONT_NUMBER_MILD ascent 82 -> digits 58 px).
// ⚠️ Connect IQ 只暴露 ascent / descent / fontHeight**没有字面高度接口**。
// 而 ascent 里包含了数字和大写字母永远用不到的变音符号空间,直接拿 ascent
// 当字面高度会导致选出来的字号明显偏小(第一版就是这么错的,时间数字只有
// 设计稿的 57%)。
//
// 下面两个比例是**实测**出来的:在模拟器里渲染,然后从截图上量墨迹高度。
// fenix847mmFONT_XTINY ascent 29 → 大写实际高 22px → 0.76
// FONT_NUMBER_MILD ascent 82 → 数字实际高 58px → 0.71
// 各机型内置字体不完全相同Roboto / RobotoCondensed / Yantramanav /
// 各机型位图字体),比例会有几个百分点误差,可以接受。
const CAP_OF_TEXT = 0.76;
const CAP_OF_NUM = 0.71;
// --------------------------------------------------------------- state
var scale = 1.0; // device px per design px
// ------------------------------- 运行时状态 ----------------------------
var scale = 1.0; // 设备像素 / 设计像素
var width = 0;
var height = 0;
var cx = 0;
@@ -99,8 +111,7 @@ module Layout {
var fontComp = Graphics.FONT_MEDIUM;
var fontTime = Graphics.FONT_NUMBER_HOT;
// Text sizes in ascending order, so a value that will not fit can walk
// down the list until it does.
// 文字字号按从小到大排列,这样放不下的值可以沿着这张表往下逐档缩。
var textFonts = [Graphics.FONT_XTINY, Graphics.FONT_TINY, Graphics.FONT_SMALL,
Graphics.FONT_MEDIUM, Graphics.FONT_LARGE];
@@ -119,7 +130,7 @@ module Layout {
function ready() as Boolean { return width > 0; }
// Design pixels to device pixels.
// 设计像素 → 设备像素。整个文件里凡是坐标都要过这一层。
function px(v as Numeric) as Number {
return Math.round(v * scale).toNumber();
}
@@ -128,8 +139,8 @@ module Layout {
return Graphics.getFontAscent(font) * ratio;
}
// Closest cap height wins -- not "largest that still fits", which throws
// away a whole size whenever the design falls between two fonts.
// 选**字面高度最接近**目标的那一档,而不是「不超过目标的最大档」。
// 后者在设计尺寸恰好卡在两档字体之间时会白白小一号。
function pickFont(cands as Array<Graphics.FontDefinition>, targetCap as Float,
ratio as Float) as Graphics.FontDefinition {
var best = cands[0];
@@ -142,8 +153,8 @@ module Layout {
return best;
}
// A long value -- twelve characters of custom text, say -- would run off
// the dial at the design's size. Step down until it fits the slot.
// 值太长时(比如 12 个字符的自定义文字、6 个汉字的长日期)按设计稿字号会
// 冲出表盘。这里沿字号表往下找,直到能塞进槽位宽度为止。
function fitFont(dc as Graphics.Dc, str as String,
font as Graphics.FontDefinition, maxDesignW as Float)
as Graphics.FontDefinition {
@@ -154,7 +165,7 @@ module Layout {
if (textFonts[i] == f) { break; }
i--;
}
if (i < 0) { return f; } // not one of the text fonts
if (i < 0) { return f; } // 不是文字字号表里的(比如数字字体),不动它
while (i >= 0) {
if (dc.getTextWidthInPixels(str, textFonts[i]) <= limit) {
return textFonts[i];
@@ -164,9 +175,9 @@ module Layout {
return textFonts[0];
}
// TEXT_JUSTIFY_VCENTER centres the font box, but capitals occupy only the
// band from the baseline up by capHeight, so the ink sits high in that box.
// Push the draw down by the difference to land the ink on cy.
// TEXT_JUSTIFY_VCENTER 居中的是**字体盒子**,而大写字母/数字只占据
// 「基线往上 capHeight」这一段在盒子里是偏上的。
// 所以要往下推一个差值,才能让实际墨迹的中心落在 cy 上。
function inkY(dc as Graphics.Dc, font as Graphics.FontDefinition,
cyDesign as Numeric, ratio as Float) as Number {
var fh = dc.getFontHeight(font);
@@ -182,17 +193,18 @@ module Layout {
just | Graphics.TEXT_JUSTIFY_VCENTER);
}
// Same, but shrinks the font if the string is too wide for its slot.
// 同上,但会在字符串超出槽位宽度时自动降字号。
function fittedText(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric,
font as Graphics.FontDefinition, str as String,
just as Number, col as Number, maxDesignW as Float) as Void {
text(dc, x, cyDesign, fitFont(dc, str, font, maxDesignW), str, just, col);
}
// Monkey C cannot fill glyphs with a gradient, so the string is drawn once
// per stop with dc.setClip holding each pass to its own horizontal band.
// The first and last band are extended off-glyph so no sliver of the
// digits is ever left undrawn.
// Monkey C 没法给文字填渐变。这里的做法是:**同一串数字画 N 遍**,每遍用
// dc.setClip 把绘制限制在一条横向色带内,色带颜色取自主题的渐变停止点。
//
// 首尾两条色带故意向外延伸到屏幕边缘,保证字形上下不会有没画到的缝隙
// (字体实际墨迹高度和我们估算的 capHeight 总有几像素出入)。
function gradText(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric,
font as Graphics.FontDefinition, str as String,
just as Number, ramp as Array<Number>) as Void {
@@ -213,7 +225,7 @@ module Layout {
dc.clearClip();
}
// Design angles run clockwise from 12 o'clock; screen y grows downward.
// 设计稿的角度自 12 点起顺时针为正;屏幕 y 轴向下为正,所以是 cy - cos。
function polarX(ang as Float, r as Float) as Number {
return Math.round(cx + Math.sin(Math.toRadians(ang)) * r).toNumber();
}
@@ -227,7 +239,7 @@ module Layout {
dc.drawLine(px(x1), px(y1), px(x2), px(y2));
}
// Pen widths round to zero on small screens; never let one vanish.
// 线宽在小屏上会被 round 成 0 直接消失,这里兜一个最小值 2。
function pen(v as Numeric) as Number {
var w = px(v);
return w < 2 ? 2 : w;