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
575 lines
24 KiB
Python
575 lines
24 KiB
Python
# 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:]))
|