OXYGENHUB
◆ main oxygen / packaging / haikuports / make-icons.py
6.1 KiB · Python · last changed HaikuDepot icons for oxy and oh (HVIF and PNGs) and the script that draws them (refs #157) 6 days ago source blame history raw
1
#!/usr/bin/env python3
2
"""HaikuDepot icons for oxy and oh (oxygen#157): the Oxygen mark as Haiku's
3
vector icon format (HVIF), and as SVG to render the 16, 32 and 64 px PNGs.
4
5
A family of two (Andy, 2026-10-29): Oxygen (oxy, the Oxygen VCS extension)
6
is the mark on a pale rounded tile with a blue right dot; OxygenHub (oh, the
7
OxygenHub extension) is the mark on the hub's dark tile with a white one, as
8
oxygenhub/assets/icons/oxygenhub.svg. Each keeps its edge on light and dark
9
backgrounds alike.
10
11
    make-icons.py OUTDIR    writes oxy.hvif, oh.hvif, oxy.svg, oh.svg
12
13
HVIF as Haiku's FlatIconImporter reads it: "ncif", then styles, paths and
14
shapes, each a count byte and its items; coordinates in a 64-unit canvas.
15
"""
16
import math
17
import struct
18
import sys
19
20
CYAN, BLUE, WHITE, TILE = (0x00, 0xF0, 0xFF), (0x00, 0x72, 0xFF), (0xFF, 0xFF, 0xFF), (0x07, 0x0C, 0x18)
21
LIGHT, EDGE = (0xF2, 0xF7, 0xFF), (0xC9, 0xDC, 0xF5)
22
K = 0.5522847498  # a circle's quarter as one cubic Bézier
23
24
25
def coord(v):
26
    if v == int(v) and -32 <= v <= 95:
27
        return bytes([int(v) + 32])
28
    n = int(round((v + 128.0) * 102.0))
29
    return bytes([(n >> 8) | 0x80, n & 0xFF])
30
31
32
def float24(v):
33
    if v == 0:
34
        return b"\0\0\0"
35
    bits = struct.unpack(">I", struct.pack(">f", v))[0]
36
    sign, exp, man = bits >> 31, ((bits >> 23) & 0xFF) - 127, bits & 0x7FFFFF
37
    if not -32 <= exp < 32:
38
        raise ValueError(f"{v} does not fit a float24")
39
    n = (sign << 23) | ((exp + 32) << 17) | (man >> 6)
40
    return n.to_bytes(3, "big")
41
42
43
def solid(rgb):
44
    return bytes([3, *rgb])  # STYLE_TYPE_SOLID_COLOR_NO_ALPHA
45
46
47
def linear(p1, p2, stops):
48
    """A linear gradient from p1 to p2: gradient space runs x from -64 to 64."""
49
    dx, dy = (p2[0] - p1[0]) / 128.0, (p2[1] - p1[1]) / 128.0
50
    mx, my = (p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2
51
    out = bytes([2, 0, (1 << 1) | (1 << 2), len(stops)])  # gradient, linear, transform | no alpha
52
    for v in (dx, dy, -dy, dx, mx, my):
53
        out += float24(v)
54
    for off, rgb in stops:
55
        out += bytes([off, *rgb])
56
    return out
57
58
59
def line(a, b):
60
    return bytes([1 << 3, 2]) + coord(a[0]) + coord(a[1]) + coord(b[0]) + coord(b[1])  # no curves, open
61
62
63
def curve_path(points, closed=True):
64
    """points: (x, y, in_x, in_y, out_x, out_y)."""
65
    out = bytes([(1 << 1) if closed else 0, len(points)])
66
    for p in points:
67
        for v in p:
68
            out += coord(v)
69
    return out
70
71
72
def circle(cx, cy, r):
73
    k = r * K
74
    pts = [(cx, cy - r, cx - k, cy - r, cx + k, cy - r), (cx + r, cy, cx + r, cy - k, cx + r, cy + k),
75
           (cx, cy + r, cx + k, cy + r, cx - k, cy + r), (cx - r, cy, cx - r, cy + k, cx - r, cy - k)]
76
    return curve_path(pts)
77
78
79
def rounded(x, y, w, h, r):
80
    k = r * (1 - K)
81
    x2, y2 = x + w, y + h
82
    pts = [
83
        (x + r, y, x + k, y, x + r, y), (x2 - r, y, x2 - r, y, x2 - k, y),
84
        (x2, y + r, x2, y + k, x2, y + r), (x2, y2 - r, x2, y2 - r, x2, y2 - k),
85
        (x2 - r, y2, x2 - k, y2, x2 - r, y2), (x + r, y2, x + r, y2, x + k, y2),
86
        (x, y2 - r, x, y2 - k, x, y2 - r), (x, y + r, x, y + r, x, y + k),
87
    ]
88
    return curve_path(pts)
89
90
91
def shape(style, paths, stroke=None, cap=0):
92
    out = bytes([10, style, len(paths), *paths])
93
    if stroke is None:
94
        return out + bytes([0])
95
    # has transformers: one stroke (width + 128, cap << 4 | round join, miter limit)
96
    return out + bytes([1 << 4, 1, 23, int(round(stroke)) + 128, (cap << 4) | 2, 4])
97
98
99
def mark(scale, ox, oy):
100
    """The mark's geometry, scaled about its centre and moved to (ox, oy)."""
101
    p = lambda x, y: (ox + x * scale, oy + y * scale)
102
    return {
103
        "top": (p(-11, -6), p(11, -6)), "bottom": (p(-11, 6), p(11, 6)),
104
        "left": (p(-11, 0), 10 * scale), "right": (p(11, 0), 10 * scale),
105
        "dot": 3.5 * scale, "stroke": 3 * scale, "grad": (p(-22, -11), p(22, 11)),
106
    }
107
108
109
def hvif(dark):
110
    m = mark(1.0, 32, 32)
111
    styles = [linear(*m["grad"], [(0, CYAN), (255, BLUE)]), solid(CYAN), solid(WHITE if dark else BLUE)]
112
    paths = [line(*m["top"]), line(*m["bottom"]), circle(*m["left"][0], m["left"][1]),
113
             circle(*m["right"][0], m["right"][1]), circle(*m["left"][0], m["dot"]), circle(*m["right"][0], m["dot"])]
114
    if dark:
115
        styles.append(solid(TILE))
116
        paths.append(rounded(0, 0, 64, 64, 14))
117
        tile = [shape(3, [6])]
118
    else:
119
        # A pale tile with a thin edge: the edge's colour, then the tile inset by it.
120
        styles += [solid(EDGE), solid(LIGHT)]
121
        paths += [rounded(0, 0, 64, 64, 14), rounded(1.5, 1.5, 61, 61, 12.5)]
122
        tile = [shape(3, [6]), shape(4, [7])]
123
    shapes = tile + [
124
        shape(0, [0], m["stroke"], cap=2), shape(0, [1], m["stroke"], cap=2),
125
        shape(0, [2], m["stroke"]), shape(0, [3], m["stroke"]),
126
        shape(1, [4]), shape(2, [5]),
127
    ]
128
    out = b"ncif"
129
    for section in (styles, paths, shapes):
130
        out += bytes([len(section)]) + b"".join(section)
131
    return out
132
133
134
def svg(dark):
135
    if dark:
136
        bg, dot = '<rect width="64" height="64" rx="14" fill="#070c18"/>', "#FFFFFF"
137
    else:
138
        bg = '<rect width="64" height="64" rx="14" fill="#C9DCF5"/><rect x="1.5" y="1.5" width="61" height="61" rx="12.5" fill="#F2F7FF"/>'
139
        dot = "#0072FF"
140
    return f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
141
  <defs>
142
    <linearGradient id="g" gradientUnits="userSpaceOnUse" x1="-22" y1="-11" x2="22" y2="11">
143
      <stop offset="0%" stop-color="#00F0FF"/>
144
      <stop offset="100%" stop-color="#0072FF"/>
145
    </linearGradient>
146
  </defs>
147
  {bg}
148
  <g transform="translate(32 32)">
149
    <path d="M -11 -6 L 11 -6" stroke="url(#g)" stroke-width="3" stroke-linecap="round"/>
150
    <path d="M -11 6 L 11 6" stroke="url(#g)" stroke-width="3" stroke-linecap="round"/>
151
    <circle cx="-11" cy="0" r="10" fill="none" stroke="url(#g)" stroke-width="3"/>
152
    <circle cx="-11" cy="0" r="3.5" fill="#00F0FF"/>
153
    <circle cx="11" cy="0" r="10" fill="none" stroke="url(#g)" stroke-width="3"/>
154
    <circle cx="11" cy="0" r="3.5" fill="{dot}"/>
155
  </g>
156
</svg>
157
'''
158
159
160
out = sys.argv[1]
161
for name, dark in (("oxy", False), ("oh", True)):
162
    open(f"{out}/{name}.hvif", "wb").write(hvif(dark))
163
    open(f"{out}/{name}.svg", "w").write(svg(dark))