源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,574 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""Regenerate the shipped app-icon artwork.
|
||||
|
||||
Every PNG under apps/desktop/assets/app-icons/ except `mono.png` comes out of
|
||||
this file. The mark is not traced, not drawn over stock art, and not generated
|
||||
by a model: it is a handful of stroked polylines defined by the constants
|
||||
below, so the provenance of the artwork is the source you are reading.
|
||||
|
||||
python3 scripts/generate-app-icons.py # rewrite every icon
|
||||
python3 scripts/generate-app-icons.py --check # verify, write nothing
|
||||
|
||||
`--check` is what the test harness uses: it re-renders and compares bytes, so
|
||||
a change to the geometry that is not reflected in the committed PNGs fails.
|
||||
The harness passes no names, so every shipped tile is compared, and rendering
|
||||
is spread across all cores to keep that affordable.
|
||||
|
||||
There is no SVG rasteriser available in CI, so the PNGs are rendered here by
|
||||
evaluating the same geometry as signed distance fields — including the miter
|
||||
wedge a sharp join adds, which a plain capsule union would miss.
|
||||
|
||||
Artwork is written at 1024x1024 with a 100px transparent margin on all four
|
||||
sides. That is the macOS icon grid: the solid block occupies 824/1024 (80.5%)
|
||||
and the dock scales the canvas without adding padding of its own, so a
|
||||
full-bleed tile would sit 24% wider than every neighbour.
|
||||
"""
|
||||
|
||||
import io
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from multiprocessing import Pool
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
SIZE = 1024.0
|
||||
CORNER_R = 229.0
|
||||
STROKE = 70.0
|
||||
HW = STROKE / 2.0
|
||||
|
||||
APEX = (512, 274)
|
||||
VALLEY = (512, 800)
|
||||
FOOT_L, FOOT_R = (180, 845), (844, 845)
|
||||
BAR_Y = 162
|
||||
BAR = [(405, BAR_Y), (619, BAR_Y)]
|
||||
# clearance between the bar's underside and the top of the tip's round join,
|
||||
# kept at ~60% of the stroke so the two never read as one shape
|
||||
BAR_GAP = (APEX[1] - HW) - (BAR_Y + HW)
|
||||
|
||||
|
||||
def shoulder(foot):
|
||||
"""Where the inner arm crosses the leg. The inner V mirrors the outer legs,
|
||||
so by symmetry the crossing sits halfway between apex and valley."""
|
||||
sy = (APEX[1] + VALLEY[1]) / 2.0
|
||||
sx = APEX[0] + (foot[0] - APEX[0]) * (sy - APEX[1]) / (foot[1] - APEX[1])
|
||||
return (round(sx, 1), sy)
|
||||
|
||||
|
||||
SHOULDER_L, SHOULDER_R = shoulder(FOOT_L), shoulder(FOOT_R)
|
||||
|
||||
|
||||
def mark_box():
|
||||
"""viewBox for the bare mark: the glyph's bounding box."""
|
||||
x0, x1 = FOOT_L[0] - HW, FOOT_R[0] + HW
|
||||
y0, y1 = BAR_Y - HW, FOOT_L[1] + HW
|
||||
return ("%g %g %g %g" % (x0, y0, x1 - x0, y1 - y0), int(x1 - x0), int(y1 - y0))
|
||||
|
||||
# How the tip meets the M, i.e. what the boundary between the two colours looks
|
||||
# like. Whoever is painted last owns the joint:
|
||||
# flat tip on top, butt cap on the shoulder — cut across the leg, and it
|
||||
# shaves off the M's corner
|
||||
# round tip on top, round cap — arc bulging down into the M
|
||||
# tangent as round, raised half a stroke so the arc just touches the shoulder
|
||||
# round-up M on top, round join — its round shoulder bulges up into the tip
|
||||
# flat-up M on top, legs run half a stroke past the shoulder, cut flat
|
||||
# miter-up M on top, sharp (miter) join — the corner runs out to a point and
|
||||
# the boundary lies along the inner arm's outer edge, i.e. parallel
|
||||
# to the M's two middle strokes
|
||||
JOINT = "miter-up"
|
||||
JOINT_STYLES = {
|
||||
"flat": dict(lift=0.0, cap="butt", m_on_top=False, join="round"),
|
||||
"round": dict(lift=0.0, cap="round", m_on_top=False, join="round"),
|
||||
"tangent": dict(lift=HW, cap="round", m_on_top=False, join="round"),
|
||||
"round-up": dict(lift=0.0, cap="butt", m_on_top=True, join="round"),
|
||||
"flat-up": dict(lift=HW, cap="butt", m_on_top=True, join="round"),
|
||||
"miter-up": dict(lift=0.0, cap="butt", m_on_top=True, join="miter"),
|
||||
}
|
||||
|
||||
|
||||
def along_leg(shoulder, lift):
|
||||
"""Walk `lift` units from a shoulder towards the apex."""
|
||||
dx, dy = APEX[0] - shoulder[0], APEX[1] - shoulder[1]
|
||||
n = math.hypot(dx, dy)
|
||||
return (shoulder[0] + dx / n * lift, shoulder[1] + dy / n * lift)
|
||||
|
||||
|
||||
def parts(joint=None):
|
||||
"""(name, polyline, cap, join), painted in order — the last one drawn owns
|
||||
the shoulder, so its outline is what the colour boundary follows."""
|
||||
s = JOINT_STYLES[joint or JOINT]
|
||||
lift, join = s["lift"], s["join"]
|
||||
bar = [("bar", BAR, "round", "round")]
|
||||
|
||||
if not s["m_on_top"]:
|
||||
body = [("body", [FOOT_L, SHOULDER_L, VALLEY, SHOULDER_R, FOOT_R],
|
||||
"round", "round")]
|
||||
tip = [("tip", [along_leg(SHOULDER_L, lift), APEX,
|
||||
along_leg(SHOULDER_R, lift)], s["cap"], "round")]
|
||||
return body + tip + bar
|
||||
|
||||
# M on top: the tip runs down to the shoulders and is painted over.
|
||||
tip = [("tip", [SHOULDER_L, APEX, SHOULDER_R], "butt", "round")]
|
||||
if join == "miter":
|
||||
# each half is one stroke so the shoulder is a real join and can be
|
||||
# mitred; they overlap at the valley, which stays round
|
||||
body = [("body", [FOOT_L, SHOULDER_L, VALLEY], "round", "miter"),
|
||||
("body", [FOOT_R, SHOULDER_R, VALLEY], "round", "miter")]
|
||||
else:
|
||||
body = [("body", [FOOT_L, SHOULDER_L, VALLEY, SHOULDER_R, FOOT_R],
|
||||
"round", "round")]
|
||||
# stubs carry the M past the shoulder and stop flat there
|
||||
body += [("body", [along_leg(s2, -lift), along_leg(s2, lift)], "butt", "round")
|
||||
for s2 in (SHOULDER_L, SHOULDER_R) if lift]
|
||||
return tip + body + bar
|
||||
|
||||
|
||||
# name, group, label, background, body, tip, bar
|
||||
V = lambda name, group, label, bg, body, tip, bar: dict(
|
||||
name=name, group=group, label=label, bg=bg, body=body, tip=tip, bar=bar)
|
||||
|
||||
|
||||
def grad(c1, c2, angle=90):
|
||||
"""A gradient paint spec, valid for the tile or for any stroked part.
|
||||
|
||||
angle: 0 = left-to-right, 90 = top-to-bottom. A bare (c1, c2) tuple is the
|
||||
original top-to-bottom form and is still accepted everywhere.
|
||||
"""
|
||||
return ("grad", c1, c2, angle)
|
||||
|
||||
|
||||
W = "#FFFFFF"
|
||||
|
||||
VARIANTS = [
|
||||
# --- 浅蓝系 ---------------------------------------------------------
|
||||
V("sky", "蓝", "原色 sky", "#47A3E2", W, W, W),
|
||||
V("cyan", "蓝", "青蓝", "#4FC3D9", W, W, W),
|
||||
V("ice", "蓝", "冰蓝渐变", ("#9BDCFB", "#4A9FE0"), W, W, W),
|
||||
V("pale-inverted", "蓝", "淡底深标", "#DCEEFB", "#2E86D6", "#2E86D6", "#2E86D6"),
|
||||
# --- 黑白 -----------------------------------------------------------
|
||||
V("ink", "黑白", "墨黑底", "#111315", W, W, W),
|
||||
V("paper", "黑白", "纸白底", "#FFFFFF", "#14161A", "#14161A", "#14161A"),
|
||||
V("graphite", "黑白", "白底灰尖", "#FFFFFF", "#14161A", "#8A9199", "#14161A"),
|
||||
# --- 铅笔 -----------------------------------------------------------
|
||||
V("pencil-kraft", "铅笔", "牛皮纸底", "#D9C7A4", "#F5B301", "#14110F", W),
|
||||
V("pencil-sky", "铅笔", "天蓝底", "#47A3E2", "#FFC531", "#17191B", W),
|
||||
V("pencil-navy", "铅笔", "深蓝底", "#1F3A5F", "#F7C948", "#0B0D10", W),
|
||||
# --- 高山 -----------------------------------------------------------
|
||||
V("alpine", "高山", "晴空雪山", ("#C8E9FF", "#79C3F0"), "#3E5C76", W, W),
|
||||
V("dusk", "高山", "黄昏", ("#FFC48C", "#F4886B"), "#35314C", "#FFF3E4", W),
|
||||
V("night", "高山", "夜山", ("#16233A", "#31527A"), "#0C1422", "#E8F1FF", "#DCE8FF"),
|
||||
V("forest", "高山", "苍绿", ("#D6ECDD", "#8FC7A8"), "#2C4A3B", W, W),
|
||||
# --- 深色 -----------------------------------------------------------
|
||||
# ink was the only true dark tile; a mid-tone one glows like a light leak
|
||||
# in a dark dock or a dark UI shell.
|
||||
V("midnight", "深色", "午夜蓝", "#0A1220", "#9EC7F0", "#E8F2FF", "#5B8DEF"),
|
||||
V("carbon", "深色", "纯黑 OLED", "#000000", "#3FA9F5", "#7FD4FF", "#3FA9F5"),
|
||||
V("slate", "深色", "石板", "#1E242C", "#8FA6BC", "#DDE7F0", "#6B8299"),
|
||||
V("obsidian", "深色", "曜石", grad("#1A1030", "#0A0616", 135),
|
||||
"#A78BFA", "#E9E2FF", "#7C5CE0"),
|
||||
# --- 霓虹 -----------------------------------------------------------
|
||||
# Amber CRT and phosphor green are the terminal's own history, not styling.
|
||||
V("neon-cyan", "霓虹", "荧光青", "#0B0F14", "#00E5FF", "#B8FBFF", "#00A8CC"),
|
||||
V("matrix", "霓虹", "磷绿", "#050A06", "#39FF14", "#C8FFB8", "#1F9E0A"),
|
||||
V("magenta", "霓虹", "品红", "#14091F", "#FF3DCE", "#FFD6F5", "#B01E8E"),
|
||||
V("amber-crt", "霓虹", "琥珀 CRT", "#0D0A05", "#FFB000", "#FFE7B0", "#C77A00"),
|
||||
# --- 莫兰迪 ---------------------------------------------------------
|
||||
V("clay", "莫兰迪", "陶土", "#C97B63", "#FFF6F0", W, "#8E4A36"),
|
||||
V("sage", "莫兰迪", "鼠尾草", "#9CAF88", "#2F3D28", "#F4F8EF", "#2F3D28"),
|
||||
V("dust", "莫兰迪", "灰粉", "#C4A69C", "#4A362F", "#FBF4F1", "#4A362F"),
|
||||
V("fog", "莫兰迪", "雾蓝", "#A8B5BF", "#2B3742", "#F2F6F9", "#2B3742"),
|
||||
# --- 暖色 -----------------------------------------------------------
|
||||
V("sunset", "暖色", "日落", grad("#FF9A5A", "#FF5E7E", 135), W, "#FFF0E8", W),
|
||||
V("amber", "暖色", "琥珀", "#F5A623", "#3D2A08", "#FFF6E2", "#3D2A08"),
|
||||
V("terracotta", "暖色", "赤陶", grad("#E2825A", "#C4553C", 135),
|
||||
"#FFF2EA", W, "#8E3A26"),
|
||||
# --- 自然 -----------------------------------------------------------
|
||||
V("ocean", "自然", "深海", grad("#0E4D64", "#1B7A8C", 160), "#DFF6F8", W, W),
|
||||
V("moss", "自然", "苔原", grad("#2F4A34", "#1C2E20", 135), "#9FD4A8", W, "#6FA97A"),
|
||||
V("desert", "自然", "沙漠", grad("#EBCF9C", "#D89A55", 135),
|
||||
"#4A2E16", "#FFF8EC", "#4A2E16"),
|
||||
V("glacier", "自然", "冰川", grad("#F0FAFF", "#BFE4F5", 135),
|
||||
"#2C5F7C", "#6FB5D8", "#2C5F7C"),
|
||||
# --- 金属(描边渐变)------------------------------------------------
|
||||
V("gold", "金属", "鎏金", "#141210", grad("#F7E7A0", "#B8860B", 120),
|
||||
grad("#FFF8D8", "#D4AF37", 120), grad("#F7E7A0", "#B8860B", 120)),
|
||||
V("chrome", "金属", "铬", "#16181C", grad("#F4F7FA", "#8A97A6", 120),
|
||||
grad("#FFFFFF", "#B8C4D0", 120), grad("#F4F7FA", "#8A97A6", 120)),
|
||||
# --- 高对比 ---------------------------------------------------------
|
||||
# Not a style but an obligation: one-colour printing, and 7:1 contrast.
|
||||
V("mono-black", "高对比", "纯黑", W, "#000000", "#000000", "#000000"),
|
||||
V("mono-white", "高对比", "纯白", "#000000", W, W, W),
|
||||
V("hazard", "高对比", "黑黄", "#111111", "#FFD400", "#FFD400", "#FFD400"),
|
||||
]
|
||||
|
||||
|
||||
# --- svg --------------------------------------------------------------------
|
||||
def svg(variant, transparent=False, joint=None, box=None, inset=0.0):
|
||||
name = variant["name"]
|
||||
bg = variant["bg"]
|
||||
# the bare mark is cropped to the glyph's bounding box
|
||||
box = box or (mark_box() if transparent else ("0 0 1024 1024", 1024, 1024))
|
||||
out = ['<svg xmlns="http://www.w3.org/2000/svg" viewBox="%s" '
|
||||
'width="%d" height="%d">' % box,
|
||||
' <title>%s</title>' % variant["label"]]
|
||||
defs = []
|
||||
|
||||
def paint_of(spec, ident, user_space):
|
||||
"""A fill/stroke value, registering a gradient def when needed.
|
||||
|
||||
Stroke gradients must be userSpaceOnUse: under the default
|
||||
objectBoundingBox each path gets its own box, so the ramp would
|
||||
restart at every seam between body, tip and bar.
|
||||
"""
|
||||
gr = as_grad(spec)
|
||||
if gr is None:
|
||||
return spec
|
||||
c1, c2, angle = gr
|
||||
if user_space:
|
||||
_, mw, mh = mark_box()
|
||||
p1, p2 = grad_axis(angle, (FOOT_L[0] - HW, BAR_Y - HW,
|
||||
float(mw), float(mh)))
|
||||
coords = ('x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" '
|
||||
'gradientUnits="userSpaceOnUse"' % (p1 + p2))
|
||||
else:
|
||||
p1, p2 = grad_axis(angle, (0.0, 0.0, 1.0, 1.0))
|
||||
coords = 'x1="%.4f" y1="%.4f" x2="%.4f" y2="%.4f"' % (p1 + p2)
|
||||
defs.append(' <linearGradient id="%s" %s>'
|
||||
'<stop offset="0" stop-color="%s"/>'
|
||||
'<stop offset="1" stop-color="%s"/></linearGradient>'
|
||||
% (ident, coords, c1, c2))
|
||||
return "url(#%s)" % ident
|
||||
|
||||
paint = paint_of(bg, "bg-" + name, False)
|
||||
strokes = {p: paint_of(variant[p], "%s-%s" % (p, name), True)
|
||||
for p in ("body", "tip", "bar")}
|
||||
if defs:
|
||||
out += [' <defs>'] + defs + [' </defs>']
|
||||
|
||||
if not transparent:
|
||||
if inset:
|
||||
out.append(' <g transform="translate(%g %g) scale(%g)">'
|
||||
% (inset * SIZE, inset * SIZE, 1 - 2 * inset))
|
||||
out.append(' <rect width="1024" height="1024" rx="229" ry="229" fill="%s"/>' % paint)
|
||||
if inset:
|
||||
out.append(' </g>')
|
||||
if inset:
|
||||
out.append(' <g transform="translate(%g %g) scale(%g)">'
|
||||
% (inset * SIZE, inset * SIZE, 1 - 2 * inset))
|
||||
out.append(' <g fill="none" stroke-width="70" stroke-miterlimit="8">')
|
||||
for part, points, cap, join in parts(joint):
|
||||
d = "M " + " L ".join("%.1f %.1f" % p for p in points)
|
||||
out.append(' <path d="%s" stroke="%s" stroke-linecap="%s" '
|
||||
'stroke-linejoin="%s"/>' % (d, strokes[part], cap, join))
|
||||
out.append(' </g>')
|
||||
if inset:
|
||||
out.append(' </g>')
|
||||
out += ['</svg>', '']
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
# --- png --------------------------------------------------------------------
|
||||
def rgb(h):
|
||||
h = h.lstrip("#")
|
||||
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
||||
|
||||
|
||||
def as_grad(spec):
|
||||
"""Normalise a paint spec to (c1, c2, angle) or None if it is flat.
|
||||
|
||||
A bare 2-tuple is the original top-to-bottom form and stays valid.
|
||||
"""
|
||||
if isinstance(spec, str):
|
||||
return None
|
||||
if spec[0] == "grad":
|
||||
return (spec[1], spec[2], spec[3] if len(spec) > 3 else 90)
|
||||
return (spec[0], spec[1], 90)
|
||||
|
||||
|
||||
def grad_axis(angle, box):
|
||||
"""The two endpoints of the gradient axis, matching how SVG maps a
|
||||
linearGradient onto a box: the unit-square endpoints scaled onto it."""
|
||||
x0, y0, w, h = box
|
||||
a = math.radians(angle)
|
||||
cx, sy = math.cos(a), math.sin(a)
|
||||
p1 = (x0 + (0.5 - cx / 2) * w, y0 + (0.5 - sy / 2) * h)
|
||||
p2 = (x0 + (0.5 + cx / 2) * w, y0 + (0.5 + sy / 2) * h)
|
||||
return p1, p2
|
||||
|
||||
|
||||
def ramp(spec, box):
|
||||
"""Compile a paint spec into f(x, y) -> (r, g, b)."""
|
||||
gr = as_grad(spec)
|
||||
if gr is None:
|
||||
c = rgb(spec)
|
||||
return lambda x, y: c
|
||||
ca, cb, angle = rgb(gr[0]), rgb(gr[1]), gr[2]
|
||||
(px, py), (qx, qy) = grad_axis(angle, box)
|
||||
dx, dy = qx - px, qy - py
|
||||
den = dx * dx + dy * dy or 1.0
|
||||
|
||||
def f(x, y):
|
||||
t = ((x - px) * dx + (y - py) * dy) / den
|
||||
t = 0.0 if t < 0.0 else (1.0 if t > 1.0 else t)
|
||||
return (ca[0] + (cb[0] - ca[0]) * t,
|
||||
ca[1] + (cb[1] - ca[1]) * t,
|
||||
ca[2] + (cb[2] - ca[2]) * t)
|
||||
return f
|
||||
|
||||
|
||||
def miter_rhombus(p, v, n):
|
||||
"""The wedge a miter join fills at vertex `v`, between segments p->v->n.
|
||||
|
||||
Returned as the full rhombus where the two stroke bands' infinite strips
|
||||
cross, not just the wedge above the vertex: the extra half sits inside the
|
||||
two bands, so this overlaps them instead of merely abutting them. Abutting
|
||||
shapes each cover ~50% of the pixels along the shared edge and the union
|
||||
would show a hairline of whatever is underneath. Returns None if straight.
|
||||
"""
|
||||
d1 = (v[0] - p[0], v[1] - p[1])
|
||||
d2 = (n[0] - v[0], n[1] - v[1])
|
||||
l1, l2 = math.hypot(*d1), math.hypot(*d2)
|
||||
d1, d2 = (d1[0] / l1, d1[1] / l1), (d2[0] / l2, d2[1] / l2)
|
||||
cross = d1[0] * d2[1] - d1[1] * d2[0]
|
||||
if abs(cross) < 1e-9:
|
||||
return None
|
||||
k = HW / cross
|
||||
pts = [(v[0] + k * (s1 * d2[0] - s2 * d1[0]),
|
||||
v[1] + k * (s1 * d2[1] - s2 * d1[1]))
|
||||
for s1 in (1, -1) for s2 in (1, -1)]
|
||||
pts.sort(key=lambda q: math.atan2(q[1] - v[1], q[0] - v[0]))
|
||||
return pts
|
||||
|
||||
|
||||
def prims(joint=None):
|
||||
"""Flatten the parts into drawing primitives with bounding boxes."""
|
||||
out = []
|
||||
for part, points, cap, join in parts(joint):
|
||||
for i in range(len(points) - 1):
|
||||
(ax, ay), (bx, by) = points[i], points[i + 1]
|
||||
ra = cap == "round" if i == 0 else join == "round"
|
||||
rb = cap == "round" if i == len(points) - 2 else join == "round"
|
||||
out.append((part, "seg", (ax, ay, bx, by, ra, rb),
|
||||
(min(ax, bx) - HW - 2, min(ay, by) - HW - 2,
|
||||
max(ax, bx) + HW + 2, max(ay, by) + HW + 2)))
|
||||
if join == "miter":
|
||||
for i in range(1, len(points) - 1):
|
||||
q = miter_rhombus(points[i - 1], points[i], points[i + 1])
|
||||
if q:
|
||||
xs, ys = [p[0] for p in q], [p[1] for p in q]
|
||||
out.append((part, "poly", q,
|
||||
(min(xs) - 2, min(ys) - 2, max(xs) + 2, max(ys) + 2)))
|
||||
return out
|
||||
|
||||
|
||||
_PRIMS = {}
|
||||
|
||||
|
||||
def prims_for(joint):
|
||||
joint = joint or JOINT
|
||||
if joint not in _PRIMS:
|
||||
_PRIMS[joint] = prims(joint)
|
||||
return _PRIMS[joint]
|
||||
|
||||
|
||||
def seg_sdf(px, py, ax, ay, bx, by, ra, rb):
|
||||
vx, vy = bx - ax, by - ay
|
||||
length = math.hypot(vx, vy)
|
||||
ux, uy = vx / length, vy / length
|
||||
wx, wy = px - ax, py - ay
|
||||
s = wx * ux + wy * uy # axial position, 0..length
|
||||
dp = abs(wx * uy - wy * ux) # perpendicular distance
|
||||
d = max(dp - HW, -s, s - length) # band with butt ends
|
||||
if ra:
|
||||
d = min(d, math.hypot(wx, wy) - HW)
|
||||
if rb:
|
||||
d = min(d, math.hypot(px - bx, py - by) - HW)
|
||||
return d
|
||||
|
||||
|
||||
def poly_sdf(px, py, pts):
|
||||
"""Exact distance to a convex polygon, negative inside."""
|
||||
best = 1e18
|
||||
pos = neg = False
|
||||
for i in range(len(pts)):
|
||||
ax, ay = pts[i]
|
||||
bx, by = pts[(i + 1) % len(pts)]
|
||||
vx, vy = bx - ax, by - ay
|
||||
wx, wy = px - ax, py - ay
|
||||
t = (wx * vx + wy * vy) / (vx * vx + vy * vy)
|
||||
t = 0.0 if t < 0.0 else (1.0 if t > 1.0 else t)
|
||||
dx, dy = wx - t * vx, wy - t * vy
|
||||
best = min(best, math.sqrt(dx * dx + dy * dy))
|
||||
if vx * wy - vy * wx > 0.0: # same side of every edge = inside,
|
||||
pos = True # whichever way the quad is wound
|
||||
else:
|
||||
neg = True
|
||||
return best if (pos and neg) else -best
|
||||
|
||||
|
||||
def render(variant, size, transparent=False, joint=None, inset=0.0):
|
||||
k = 1.0 - 2.0 * inset
|
||||
scale = size / SIZE * k # pixels per design unit
|
||||
aa = 1.0 / scale # antialiasing width, design units
|
||||
origin = inset * SIZE * (size / SIZE) # margin, in pixels
|
||||
half = SIZE / 2.0
|
||||
inner = half - CORNER_R
|
||||
prim = prims_for(joint)
|
||||
order = list(dict.fromkeys(p for p, _, _, _ in prim))
|
||||
# the tile gradient spans the canvas; a stroke gradient spans the glyph's
|
||||
# bounding box, so it matches the SVG's userSpaceOnUse ramp exactly
|
||||
_, mw, mh = mark_box()
|
||||
mbox = (FOOT_L[0] - HW, BAR_Y - HW, float(mw), float(mh))
|
||||
bg_paint = ramp(variant["bg"], (0.0, 0.0, SIZE, SIZE))
|
||||
part_paint = {p: ramp(variant[p], mbox) for p in order}
|
||||
|
||||
rows = []
|
||||
for iy in range(size):
|
||||
y = (iy + 0.5 - origin) / scale
|
||||
row = bytearray(b"\x00") # PNG filter: none
|
||||
for ix in range(size):
|
||||
x = (ix + 0.5 - origin) / scale
|
||||
|
||||
cover = {}
|
||||
for part, kind, data, (x0, y0, x1, y1) in prim:
|
||||
if x < x0 or x > x1 or y < y0 or y > y1:
|
||||
continue
|
||||
d = (seg_sdf(x, y, *data) if kind == "seg"
|
||||
else poly_sdf(x, y, data))
|
||||
if d < aa:
|
||||
c = min(1.0, max(0.0, 0.5 - d / aa))
|
||||
if c > cover.get(part, 0.0):
|
||||
cover[part] = c
|
||||
|
||||
if transparent:
|
||||
alpha = 0.0
|
||||
r = g = b = 0.0
|
||||
else:
|
||||
qx = max(abs(x - half) - inner, 0.0)
|
||||
qy = max(abs(y - half) - inner, 0.0)
|
||||
d = math.hypot(qx, qy) - CORNER_R
|
||||
alpha = min(1.0, max(0.0, 0.5 - d / aa))
|
||||
r, g, b = bg_paint(x, y)
|
||||
|
||||
for part in order:
|
||||
c = cover.get(part, 0.0)
|
||||
if not c:
|
||||
continue
|
||||
if not transparent:
|
||||
c = min(c, alpha) # never paint outside the tile
|
||||
else:
|
||||
alpha = max(alpha, c)
|
||||
cr, cg, cb = part_paint[part](x, y)
|
||||
r += (cr - r) * c
|
||||
g += (cg - g) * c
|
||||
b += (cb - b) * c
|
||||
|
||||
px = (r, g, b, alpha * 255)
|
||||
row += bytes(0 if c < 0 else (255 if c > 255 else int(c + .5)) for c in px)
|
||||
rows.append(bytes(row))
|
||||
return b"".join(rows)
|
||||
|
||||
|
||||
def write_png(path, size, raw, height=None, rgb=False):
|
||||
with open(path, "wb") as handle:
|
||||
write_png_stream(handle, size, raw, height=height, rgb=rgb)
|
||||
|
||||
|
||||
def write_png_stream(handle, size, raw, height=None, rgb=False):
|
||||
"""Write raw scanlines out as a PNG.
|
||||
|
||||
Defaults to the square RGBA image every icon is; `height` and `rgb` exist
|
||||
for the contact sheet, which is neither.
|
||||
"""
|
||||
color_type = 2 if rgb else 6 # 2 = RGB, 6 = RGBA
|
||||
h = height or size
|
||||
|
||||
def chunk(tag, data):
|
||||
return (struct.pack(">I", len(data)) + tag + data
|
||||
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
|
||||
fh = handle
|
||||
if True:
|
||||
fh.write(b"\x89PNG\r\n\x1a\n")
|
||||
fh.write(chunk(b"IHDR",
|
||||
struct.pack(">IIBBBBB", size, h, 8, color_type, 0, 0, 0)))
|
||||
fh.write(chunk(b"IDAT", zlib.compress(raw, 9)))
|
||||
fh.write(chunk(b"IEND", b""))
|
||||
|
||||
|
||||
MACOS_MARGIN = 100 / 1024.0
|
||||
ART_SIZE = 1024
|
||||
|
||||
|
||||
def art_directory():
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.join(here, "..", "apps", "desktop", "assets", "app-icons")
|
||||
|
||||
|
||||
def _render_one(variant):
|
||||
"""Render one icon. Top-level so it survives the pickling a Pool does."""
|
||||
return variant["name"], render(variant, ART_SIZE, inset=MACOS_MARGIN)
|
||||
|
||||
|
||||
def main(argv):
|
||||
check = "--check" in argv
|
||||
# Positional names limit the run to a subset. This is a convenience for
|
||||
# working on one colourway by hand — the test deliberately passes none, so
|
||||
# CI always compares the whole catalogue.
|
||||
wanted = {a for a in argv if not a.startswith("-")}
|
||||
selected = [v for v in VARIANTS if not wanted or v["name"] in wanted]
|
||||
unknown = wanted - {v["name"] for v in VARIANTS}
|
||||
if unknown:
|
||||
print("unknown icon(s): %s" % ", ".join(sorted(unknown)))
|
||||
return 2
|
||||
dest = art_directory()
|
||||
stale = []
|
||||
# Rasterising 38 tiles at 1024px is ~90s of single-threaded signed-distance
|
||||
# evaluation, which is the difference between checking the whole catalogue
|
||||
# in CI and checking a sample of it. The work is per-icon and pure, so it
|
||||
# parallelises exactly.
|
||||
workers = min(len(selected), os.cpu_count() or 1)
|
||||
if workers > 1:
|
||||
with Pool(workers) as pool:
|
||||
rendered = pool.map(_render_one, selected)
|
||||
else:
|
||||
rendered = [_render_one(v) for v in selected]
|
||||
|
||||
for name, raw in rendered:
|
||||
path = os.path.join(dest, name + ".png")
|
||||
if check:
|
||||
buffer = io.BytesIO()
|
||||
write_png_stream(buffer, ART_SIZE, raw)
|
||||
with open(path, "rb") as handle:
|
||||
if handle.read() != buffer.getvalue():
|
||||
stale.append(name)
|
||||
else:
|
||||
write_png(path, ART_SIZE, raw)
|
||||
if not check:
|
||||
print("wrote %d icons to %s" % (len(selected), dest))
|
||||
return 0
|
||||
if stale:
|
||||
print("out of date: %s" % ", ".join(stale))
|
||||
print("run: python3 scripts/generate-app-icons.py")
|
||||
return 1
|
||||
print("all %d icons match the committed artwork" % len(selected))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user