152 lines
5.2 KiB
Python
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()
|