Initial commit: Fenix 8 V3 data watch face

- Restore watchface-kit design (500x500, scaled per device)
- 7 themes auto-generated from tokens via gen_themes.py -> Themes.mc
- Four-segment gradient progress ring (22+21 ticks, 4 anchor dots)
- Center time, date band w/ Bluetooth icon, top battery + dual complications, bottom distance/steps
- 17 devices compiled clean (SDK 9.1.0)
- Includes browser/JS preview (preview.html) + 7-theme SVG sheet
This commit is contained in:
2026-09-09 14:40:33 +08:00
commit 553c55026a
15 changed files with 1404 additions and 0 deletions

172
gen_preview_svg.py Normal file
View File

@@ -0,0 +1,172 @@
#!/usr/bin/env python3
# 纯标准库生成表盘运行效果 SVG复用 Fenix8V3View.mc 的坐标与主题色)
import math
THEMES = {
"01":{"name":"Ember","ringTop":[[8,0xFFF203],[15,0xFDDB0D],[25,0xFFC203],[35,0xFFA00A],[45,0xFF8705],[55,0xFF610C],[65,0xFF4904],[75,0xFF2602],[86,0xFF2604]],
"ringBottom":[[7,0xAE1706],[14,0xB32504],[21,0xC03B03],[28,0xCD6206],[35,0xD57805],[42,0xDE9208],[47,0xC98D00]],
"tickOff":0x545655,"anchorDot":0x525551,"bandFill":0xFEAA00,"bandText":0x000002,"accent":0xFCA901,"textPrimary":0xFAFAFA,"colon":0x525551,"hours":0xFBFBFB,"minutes":0xFCA901},
"02":{"name":"Aurora","ringTop":[[8,0xA5FA11],[15,0x9EFA23],[25,0x97F84B],[35,0x8CF965],[45,0x89F87F],[55,0x74FB9F],[65,0x6DFCC6],[75,0x64FCDF],[86,0x57FDFF]],
"ringBottom":[[7,0x55FDFF],[14,0x64FCDF],[21,0x6FFCC6],[28,0x74FBAE],[35,0x7EFA85],[42,0x88F96D],[47,0x81E654]],
"tickOff":0x545655,"anchorDot":0x0F3C82,"bandFill":0x525551,"bandText":0xFFFFFF,"accent":0xABA9AC,"textPrimary":0xFAFAFA,"colon":0x525551,"hours":0xFBFBFB,"minutes":0xAAF701},
"03":{"name":"Brass","ringTop":[[8,0xB4814F],[15,0xB88954],[25,0xC5975C],[35,0xC59E65],[45,0xCBA775],[55,0xD7AE7F],[65,0xDEBC84],[75,0xE7C48D],[86,0xF0D097]],
"ringBottom":[[7,0xB97E4A],[14,0xB68954],[21,0xBD925E],[28,0xC69B67],[35,0xCEA26C],[42,0xD9AB74],[47,0xC59E70]],
"tickOff":0x3A2815,"anchorDot":0x62441E,"bandFill":0x392810,"bandText":0xFFFFFF,"accent":0xB37C4A,"textPrimary":0xEBCB93,"colon":0x525551,"hours":0xFBFBFB,"minutes":0xECCC93},
"04":{"name":"Voltage","ringTop":[[8,0x5CAFF0],[15,0x6EB6E0],[25,0x7FBEC5],[35,0x96CA9D],[45,0xAAD87F],[55,0xBFDF66],[65,0xD6EA40],[75,0xE8F227],[86,0xFFFB0A]],
"ringBottom":[[7,0xF7FA11],[14,0xE9F225],[21,0xD6EB3F],[28,0xC0DE65],[35,0xADD880],[42,0xA0CF98],[47,0x88B98E]],
"tickOff":0x545655,"anchorDot":0x53AAFF,"bandFill":0x53AAFF,"bandText":0x000000,"accent":0xABA9AC,"textPrimary":0x51A7FA,"colon":0x525551,"hours":0xFBFBFB,"minutes":0x52A8FD},
"05":{"name":"Reef","ringTop":[[8,0xF87E0D],[15,0xE78E25],[25,0xCE9B4D],[35,0xBDAC65],[45,0xA5BE88],[55,0x95CA9E],[65,0x7EDEC7],[75,0x6DEBE1],[86,0x57FDFF]],
"ringBottom":[[7,0xFF7905],[14,0xE78B29],[21,0xD59C3E],[28,0xBEAC63],[35,0xADB77F],[42,0xA0C796],[47,0x88B98E]],
"tickOff":0x545655,"anchorDot":0xFE7900,"bandFill":0x52FDFF,"bandText":0x000000,"accent":0xFCA901,"textPrimary":0xFAFAFA,"colon":0x525551,"hours":0xFBFBFB,"minutes":0xFC7801},
"06":{"name":"Acid","ringTop":[[8,0x9CCC14],[15,0xA9CE0C],[25,0xA4D610],[35,0xACDB08],[45,0xB4E107],[55,0xB5E603],[65,0xBFEE04],[75,0xBDF108],[86,0xC7F808]],
"ringBottom":[[7,0xC7FA02],[14,0xBEF20A],[21,0xC0ED04],[28,0xB4E905],[35,0xB4E107],[42,0xADDD0C],[47,0x9BC603]],
"tickOff":0x545655,"anchorDot":0xF58909,"bandFill":0x525551,"bandText":0x000000,"accent":0xC4F801,"textPrimary":0xFAFAFA,"colon":0x525551,"hours":0xDBF731,"minutes":0xC4F900},
"07":{"name":"Kelp","ringTop":[[8,0xEF8917],[15,0xD8901C],[25,0xBD9A34],[35,0xAD9E3C],[45,0x96A54E],[55,0x7DAE5D],[65,0x61B474],[75,0x4EBB84],[86,0x36C294]],
"ringBottom":[[7,0xCE520F],[14,0xB1631D],[21,0xA26A2F],[28,0x9D763E],[35,0x85874B],[42,0x76915D],[47,0x5E8651]],
"tickOff":0x545655,"anchorDot":0xF58909,"bandFill":0x525551,"bandText":0x000000,"accent":0x31C192,"textPrimary":0xFAFAFA,"colon":0x525551,"hours":0xF28708,"minutes":0xF4880A},
}
ORDER=["01","02","03","04","05","06","07"]
TOP=[a for a in range(2,87,4)]; BOT=[a for a in range(6,67,3)]
DOW=["SUN","MON","TUE","WED","THU","FRI","SAT"]; MON=["JAN","FEB","MAR","APR","MAY","JUN","JUL","AUG","SEP","OCT","NOV","DEC"]
def hx(c): return "#%06x"%(c&0xffffff)
def lerp(c1,c2,f):
r1=(c1>>16)&255;g1=(c1>>8)&255;b1=c1&255
r2=(c2>>16)&255;g2=(c2>>8)&255;b2=c2&255
return ((round(r1+(r2-r1)*f))<<16)|((round(g1+(g2-g1)*f))<<8)|round(b1+(b2-b1)*f)
def lerp_stops(stops,t):
n=len(stops)
if not n: return 0x888888
if t<=stops[0][0]: return stops[0][1]
if t>=stops[-1][0]: return stops[-1][1]
for i in range(n-1):
a,b=stops[i],stops[i+1]
if a[0]<=t<=b[0]:
return lerp(a[1],b[1],(t-a[0])/(b[0]-a[0]))
return stops[-1][1]
def tick(cx,cy,rIn,rOut,w,ang,col,mirror):
eff=-ang if mirror else ang; rad=math.radians(eff); s=math.sin(rad); c=math.cos(rad)
return f'<line x1="{cx+s*rIn:.2f}" y1="{cy-c*rIn:.2f}" x2="{cx+s*rOut:.2f}" y2="{cy-c*rOut:.2f}" stroke="{hx(col)}" stroke-width="{w}" stroke-linecap="round"/>'
def ring(theme,pTR,pTL,pBR,pBL):
out=[]
rOut=249.5;rIn=231.5;off=theme["tickOff"];ts=theme["ringTop"];bs=theme["ringBottom"]
topW=13;botW=9
litTR=round(pTR*22);litTL=round(pTL*22);litBR=round(pBR*21);litBL=round(pBL*21)
# TR anchorOuter, mirror=false
for i,a in enumerate(TOP):
on=i>=(22-litTR); col=lerp_stops(ts,a) if on else off
out.append(tick(250,250,rIn,rOut,topW,a,col,False))
# TL anchorOuter, mirror=true
for i,a in enumerate(TOP):
on=i>=(22-litTL); col=lerp_stops(ts,a) if on else off
out.append(tick(250,250,rIn,rOut,topW,a,col,True))
# BL mirror=true
for i,a in enumerate(BOT):
col=lerp_stops(bs,a) if i<litBL else off
out.append(tick(250,250,rIn,rOut,botW,180-a,col,True))
# BR mirror=false
for i,a in enumerate(BOT):
col=lerp_stops(bs,a) if i<litBR else off
out.append(tick(250,250,rIn,rOut,botW,180-a,col,False))
# anchor dots
dc=theme["anchorDot"];dr=9.5;drad=240.5
for da in [0,93.3,180,266.7]:
rad=math.radians(da)
out.append(f'<circle cx="{250+math.sin(rad)*drad:.2f}" cy="{250-math.cos(rad)*drad:.2f}" r="{dr}" fill="{hx(dc)}"/>')
return "".join(out)
def batt(theme,pct):
y=53.5;gw=30;gh=16;gx=250-52;gy=y-gh/2;ac=theme["accent"];tp=theme["textPrimary"]
fill_w=(gw-4)*pct/100
return (f'<rect x="{gx}" y="{gy}" width="{gw}" height="{gh}" fill="none" stroke="{hx(ac)}" stroke-width="2"/>'
f'<rect x="{gx+gw}" y="{gy+gh*0.28}" width="3" height="{gh*0.44}" fill="{hx(ac)}"/>'
f'<rect x="{gx+2}" y="{gy+2}" width="{fill_w:.2f}" height="{gh-4}" fill="{hx(ac)}"/>'
f'<text x="{250+16}" y="{y}" fill="{hx(tp)}" font-size="16" font-family="sans-serif" text-anchor="start" dominant-baseline="central">{round(pct)}%</text>')
def complic_icon(x,y,r,col,t):
c=hx(col)
if t==0: # sun
s=f'<circle cx="{x}" cy="{y}" r="{r*0.45}" fill="{c}"/>'
for i in range(8):
rad=math.radians(i*45)
s+=f'<line x1="{x+math.sin(rad)*r*0.6:.2f}" y1="{y-math.cos(rad)*r*0.6:.2f}" x2="{x+math.sin(rad)*r:.2f}" y2="{y-math.cos(rad)*r:.2f}" stroke="{c}" stroke-width="2"/>'
return s
if t==1: # flame
return f'<polygon points="{x},{y-r} {x+r*0.8:.2f},{y+r*0.6:.2f} {x-r*0.8:.2f},{y+r*0.6:.2f}" fill="{c}"/>'
if t==2: # steps
return (f'<line x1="{x-r*0.5:.2f}" y1="{y-r*0.3:.2f}" x2="{x+r*0.5:.2f}" y2="{y-r*0.3:.2f}" stroke="{c}" stroke-width="2"/>'
f'<line x1="{x-r*0.3:.2f}" y1="{y}" x2="{x+r*0.6:.2f}" y2="{y}" stroke="{c}" stroke-width="2"/>')
if t==3: # dot
return f'<circle cx="{x}" cy="{y}" r="{r*0.5}" fill="{c}"/>'
if t==4: # chevron
return f'<polygon points="{x},{y-r} {x+r*0.7:.2f},{y+r*0.2:.2f} {x-r*0.7:.2f},{y+r*0.2:.2f}" fill="{c}"/>'
return f'<rect x="{x-r*0.6:.2f}" y="{y-r*0.4:.2f}" width="{r*1.2:.2f}" height="{r*0.8:.2f}" fill="none" stroke="{c}" stroke-width="2"/>'
def top_comp(theme,t,isLeft,val):
sx=156 if isLeft else 343; iy=90; vy=130.5
return (f'<text x="{sx}" y="{vy}" fill="{hx(theme["textPrimary"])}" font-size="22" font-family="sans-serif" text-anchor="middle" dominant-baseline="central">{val}</text>'
+complic_icon(sx,iy,13,theme["accent"],t))
def bluetooth(x,y,r,col):
c=hx(col)
return (f'<path d="M{x},{y-r} L{x},{y+r} M{x},{y-r} L{x+r*0.7:.2f},{y-r*0.3:.2f} L{x},{y} '
f'L{x+r*0.7:.2f},{y+r*0.3:.2f} L{x},{y+r}" fill="none" stroke="{c}" stroke-width="3" stroke-linejoin="round"/>')
def date_band(theme,gi):
by=286.5;bh=44;yc=308.5;bf=theme["bandFill"];bt=theme["bandText"]
dow=DOW[gi["dow"]-1]; md=MON[gi["m"]-1]+" "+str(gi["day"])
return (f'<rect x="0" y="{by}" width="500" height="{bh}" fill="{hx(bf)}"/>'
+bluetooth(36.5,yc,10,bt)
+f'<text x="177.5" y="{yc}" fill="{hx(bt)}" font-size="16" font-family="sans-serif" text-anchor="middle" dominant-baseline="central">{dow}</text>'
+f'<text x="297.5" y="{yc}" fill="{hx(bt)}" font-size="16" font-family="sans-serif" text-anchor="middle" dominant-baseline="central">{md}</text>'
+f'<text x="465" y="{yc}" fill="{hx(bt)}" font-size="16" font-family="sans-serif" text-anchor="end" dominant-baseline="central">{"PM" if gi["h"]>=12 else "AM"}</text>')
def time(theme,gi):
h=gi["h"]; h=12 if h==0 else (h-12 if h>12 else h)
hs=str(h); ms="%02d"%gi["min"]; ty=220
return (f'<text x="211" y="{ty}" fill="{hx(theme["hours"])}" font-size="66" font-weight="700" font-family="sans-serif" text-anchor="end" dominant-baseline="central">{hs}</text>'
f'<text x="272" y="{ty}" fill="{hx(theme["minutes"])}" font-size="66" font-weight="700" font-family="sans-serif" text-anchor="start" dominant-baseline="central">{ms}</text>'
f'<rect x="238" y="184" width="24" height="20" fill="{hx(theme["colon"])}"/>'
f'<rect x="238" y="235" width="24" height="20" fill="{hx(theme["colon"])}"/>')
def bottom(theme,dist,steps):
vy=366;ly=410.5;lx=234;rx=266;tp=theme["textPrimary"];ac=theme["accent"]
return (f'<text x="{lx}" y="{vy}" fill="{hx(tp)}" font-size="22" font-family="sans-serif" text-anchor="end" dominant-baseline="central">{dist}</text>'
f'<text x="{lx}" y="{ly}" fill="{hx(ac)}" font-size="16" font-family="sans-serif" text-anchor="end" dominant-baseline="central">DST</text>'
f'<text x="{rx}" y="{vy}" fill="{hx(tp)}" font-size="22" font-family="sans-serif" text-anchor="start" dominant-baseline="central">{steps}</text>'
f'<text x="{rx}" y="{ly}" fill="{hx(ac)}" font-size="16" font-family="sans-serif" text-anchor="start" dominant-baseline="central">STEP</text>')
def face(theme_id,m,battery,temp,gi):
th=THEMES[theme_id]
p=[m[0],m[1],m[2],m[3],m[4]]
idxTR,idxTL,idxBR,idxBL=0,1,4,3
s=[]
s.append(f'<rect x="0" y="0" width="500" height="500" fill="#000"/>')
s.append(ring(th,p[idxTR],p[idxTL],p[idxBR],p[idxBL]))
s.append(batt(th,battery))
s.append(top_comp(th,1,True,f'{round(m[1]*2000):,}')) # 左上 卡路里
s.append(top_comp(th,0,False,f'{temp}\u00b0')) # 右上 天气
s.append(time(th,gi))
s.append(date_band(th,gi))
s.append(bottom(th,f'{m[2]*10:.1f}',f'{round(m[0]*10000):,}'))
return "".join(s)
# 七主题合成图:单排行,每格 230px圆形裁切
m=[0.84,0.64,0.65,0.80,0.40]; battery=78; temp=23
gi={"h":10,"min":8,"day":9,"m":9,"dow":3}
cell=230; gap=24; n=len(ORDER); W=n*cell+(n+1)*gap; H=cell+70
parts=[f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}">']
parts.append(f'<rect width="{W}" height="{H}" fill="#0a0a0a"/>')
for i,tid in enumerate(ORDER):
ox=gap+i*(cell+gap); oy=24
sc=cell/500.0
parts.append(f'<defs><clipPath id="c{i}"><circle cx="{ox+cell/2}" cy="{oy+cell/2}" r="{cell/2-2}"/></clipPath></defs>')
parts.append(f'<g clip-path="url(#c{i})"><g transform="translate({ox},{oy}) scale({sc:.4f})">'+face(tid,m,battery,temp,gi)+'</g></g>')
parts.append(f'<text x="{ox+cell/2}" y="{oy+cell+22}" fill="#cfccc4" font-size="15" font-family="sans-serif" text-anchor="middle">{i+1} · {THEMES[tid]["name"]}</text>')
parts.append('</svg>')
svg="\n".join(parts)
with open("/Users/ericwyuan/Desktop/Work/fenix8v3-watchface/preview-themes.svg","w") as f:
f.write(svg)
print("written preview-themes.svg", len(svg), "bytes; size", W,"x",H)