Vestibule/scripts/make-icons.py

152 lines
5.2 KiB
Python

#!/usr/bin/env python3
"""Generate the Vestibule icon set (SVG + PNG sizes + Windows .ico).
Design: a doorway arch (a vestibule is an entrance hall) in cyan on a
deep-slate rounded square, with an amber visitor dot — the kiosk is the
doorway, the public is the visitor.
Outputs (into <repo>/packaging/icons/):
vestibule.svg scalable source (Flatpak/scalable)
vestibule-<N>.png 16..256 px hicolor sizes (Flatpak)
vestibule.ico multi-size Windows icon (Inno Setup)
Usage: python3 scripts/make-icons.py
"""
import os
from PIL import Image, ImageDraw
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(REPO, "packaging", "icons")
BG = (15, 23, 42, 255) # slate-900
BG_EDGE = (30, 41, 59, 255) # slate-800 rim
CYAN = (56, 189, 248, 255) # sky-400 arch
AMBER = (251, 191, 36, 255) # amber-400 visitor
SIZES = [16, 32, 48, 64, 128, 256]
SIMPLE_SIZE_MAX = 32 # at or below: legibility beats detail
def geometry(size: int) -> dict:
"""Scale table: every dimension derives from the canvas size."""
s = size
inset_x = int(s * 0.30)
arch_w = s - 2 * inset_x
return {
"s": s,
"radius": max(2, int(s * 0.18)),
"rim_w": max(1, int(s * 0.023)),
"stroke": max(2, int(s * 0.075)),
"inset_x": inset_x,
"arch_top": int(s * 0.18),
"leg_bottom": int(s * 0.82),
"arch_w": arch_w,
"arch_cx": s // 2,
"arch_r": arch_w // 2,
"dot_r": max(2, int(s * 0.085)),
}
def draw_arch(d, g, stroke, with_threshold=True):
"""Doorway arch (dome + legs) + visitor dot; threshold optional
(small canvases drop it for legibility)."""
s, x, w = g["s"], g["inset_x"], g["arch_w"]
dome_bottom = g["arch_top"] + g["arch_r"]
# Dome (top half circle outline).
d.arc([x, g["arch_top"], x + w, g["arch_top"] + w],
start=180, end=360, fill=CYAN, width=stroke)
# Legs.
d.line([x, dome_bottom, x, g["leg_bottom"]], fill=CYAN, width=stroke)
d.line([s - x, dome_bottom, s - x, g["leg_bottom"]], fill=CYAN, width=stroke)
if with_threshold:
d.line([x - int(s * 0.04), g["leg_bottom"], s - x + int(s * 0.04), g["leg_bottom"]],
fill=CYAN, width=stroke)
# Visitor: amber dot on the threshold, centered.
dot_cy = g["leg_bottom"] - int(s * 0.02) - g["dot_r"]
d.ellipse([g["arch_cx"] - g["dot_r"], dot_cy - g["dot_r"],
g["arch_cx"] + g["dot_r"], dot_cy + g["dot_r"]], fill=AMBER)
def draw_full_icon(size: int) -> Image.Image:
"""Full-detail icon: rimmed background + arch."""
g = geometry(size)
s = g["s"]
img = Image.new("RGBA", (s, s), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
# Background: rounded square with a subtle rim.
d.rounded_rectangle([0, 0, s - 1, s - 1], radius=g["radius"], fill=BG)
d.rounded_rectangle([g["rim_w"], g["rim_w"], s - 1 - g["rim_w"], s - 1 - g["rim_w"]],
radius=max(1, g["radius"] - g["rim_w"]), outline=BG_EDGE,
width=g["rim_w"])
draw_arch(d, g, g["stroke"])
return img
def draw_simple_icon(size: int) -> Image.Image:
"""Legibility variant for small canvases: rim and threshold dropped,
minimum stroke widths."""
g = geometry(size)
s = g["s"]
img = Image.new("RGBA", (s, s), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.rounded_rectangle([0, 0, s - 1, s - 1], radius=g["radius"], fill=BG)
draw_arch(d, g, max(2, g["stroke"]), with_threshold=False)
return img
def draw_icon(size: int) -> Image.Image:
"""Step-down: small canvases use the simple variant, all others full."""
if size <= SIMPLE_SIZE_MAX:
return draw_simple_icon(size)
return draw_full_icon(size)
SVG = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<!-- Vestibule: a doorway arch with a visitor. Generated by scripts/make-icons.py -->
<rect width="256" height="256" rx="46" fill="#0f172a"/>
<rect x="6" y="6" width="244" height="244" rx="42" fill="none"
stroke="#1e293b" stroke-width="6"/>
<g fill="none" stroke="#38bdf8" stroke-width="19" stroke-linecap="round">
<path d="M 77 128 A 51 51 0 0 1 179 128"/>
<line x1="77" y1="128" x2="77" y2="210"/>
<line x1="179" y1="128" x2="179" y2="210"/>
<line x1="67" y1="210" x2="189" y2="210"/>
</g>
<circle cx="128" cy="176" r="21" fill="#fbbf24"/>
</svg>
"""
def main():
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "vestibule.svg"), "w") as f:
f.write(SVG)
rendered = {size: render_and_save(size) for size in SIZES}
# Windows .ico: multi-size, PNG-compressed entries for large sizes.
rendered[256].save(
os.path.join(OUT, "vestibule.ico"),
format="ICO",
sizes=[(s, s) for s in (16, 32, 48, 64, 128, 256)],
append_images=[rendered[s] for s in (128, 64, 48, 32, 16)],
)
print(f"wrote icon set to {OUT}")
print("\n".join(
f" {name} ({os.path.getsize(os.path.join(OUT, name))} bytes)"
for name in sorted(os.listdir(OUT))
))
def render_and_save(size: int) -> Image.Image:
img = draw_icon(size)
img.save(os.path.join(OUT, f"vestibule-{size}.png"))
return img
if __name__ == "__main__":
main()