branding: SimurgArray brand kit (mark, wordmark, lockups, icons, templates, guide)

Frontal dotted simurg on a bead grid: gold body and wings, three-dot crown,
red / gold / green channel plumes with peacock eyes. Detailed (31x27), simple
(11x10) and micro (7x7) drawings; dark, light, gold, black, white versions;
outlined Noto Sans wordmark; favicon and app icons; README banner, social
preview, avatar, featured-image template, report corner mark, CLI banner.
Checks: WCAG contrast table, protan/deutan simulation, icon sizes, family
sheet. Blog featured images regenerated with the new mark for consistency.
Geometry shared in scripts/brand/simurg_mark.py.
This commit is contained in:
kemal committed 2026-09-30 07:51:52 +01:00
1 parent 080814e703
commit 785d2659b6
84 files changed
+823 -143

No files matched your search

+452
View File
@@ -0,0 +1,452 @@
"""SimurgArray brand kit: marks, wordmark, lockups, icons, templates and checks → branding/.
Needs Pillow and fontTools (for the wordmark outlines), rsvg-convert and ImageMagick:
python -m venv /tmp/b && /tmp/b/bin/pip install pillow fonttools && /tmp/b/bin/python scripts/brand/make_brand.py
The mark is drawn on a dot grid in grid units (capsules and circles tested at each cell centre), so every export is
the same set of dots: a frontal simurg with raised wings, a three-dot crown and three tail plumes (red channel,
gold, green channel) ending in peacock eyes.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.pens.transformPen import TransformPen
from fontTools.ttLib import TTFont
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "branding"
FONTS = Path("/usr/share/fonts/truetype/noto")
C = {
"indigo": "#1E2240", "indigo_deep": "#12142A", "paper": "#F7F5EF", "red": "#C8453A", "green": "#2F8F5B",
"gold": "#D4A017", "gold_deep": "#A87A00", "gold_ink": "#8C6500", "muted_dark": "#A3A7C9",
"muted_light": "#5A5E7E", "black": "#111111", "white": "#FFFFFF",
} # fmt: skip
CHART = {"AA": ("#D55E00", "circle"), "AB": ("#009E73", "triangle"), "BB": ("#0072B2", "square")} # Okabe and Ito
SCHEMES = { # layer colours per version
"dark": {"gold": C["gold"], "red": C["red"], "green": C["green"], "bg": C["indigo"], "text": C["paper"]},
"light": {"gold": C["gold_deep"], "red": C["red"], "green": C["green"], "bg": C["paper"], "text": C["indigo"]},
"gold": {"gold": C["gold"], "red": C["gold"], "green": C["gold"], "bg": None, "text": C["gold"]},
"black": {"gold": C["black"], "red": C["black"], "green": C["black"], "bg": None, "text": C["black"]},
"white": {"gold": C["white"], "red": C["white"], "green": C["white"], "bg": None, "text": C["white"]},
}
ACCENT = {"dark": C["gold"], "light": C["gold_ink"], "gold": C["gold"], "black": C["black"], "white": C["white"]}
# ---------------------------------------------------------------- the mark (geometry in simurg_mark.py)
sys.path.insert(0, str(Path(__file__).resolve().parent))
from simurg_mark import MICRO, SIMPLE, Grid, art, detailed # noqa: E402
MARKS = {"mark": detailed(), "mark_simple": art(SIMPLE), "mark_micro": art(MICRO)}
def mark_svg_body(grid: Grid, scheme: str, x0: float = 0, y0: float = 0, cell: float = 1.0, rad: float = 0.4) -> str:
col = SCHEMES[scheme]
out = []
for layer in ("gold", "red", "green"):
cells = sorted((rc for rc, lay in grid.items() if lay == layer), key=lambda k: (k[1], k[0]))
dots = [f'<circle cx="{x0 + (c + 0.5) * cell:.3f}" cy="{y0 + (r + 0.5) * cell:.3f}" r="{rad * cell:.3f}"/>'
for c, r in cells] # fmt: skip
if dots:
out.append(f'<g fill="{col[layer]}">' + "".join(dots) + "</g>")
return "".join(out)
def svg(w: float, h: float, body: str, bg: str | None = None, title: str = "SimurgArray") -> str:
rect = f'<rect width="{w}" height="{h}" fill="{bg}"/>' if bg else ""
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w:g} {h:g}" width="{w:g}" height="{h:g}" '
f'role="img" aria-label="{title}"><title>{title}</title>{rect}{body}</svg>\n') # fmt: skip
# ---------------------------------------------------------------- the wordmark (outlined Noto Sans SemiBold)
class Wordmark:
def __init__(self, path: Path) -> None:
self.font = TTFont(path)
self.gs = self.font.getGlyphSet()
self.cmap = self.font.getBestCmap()
self.upm = self.font["head"].unitsPerEm
self.hmtx = self.font["hmtx"]
def paths(self, text: str, size: float, x: float, baseline: float, tracking: float = 0.0) -> tuple[str, float]:
"""SVG path data for ``text`` at ``size`` px, starting at x; returns (d, advance)."""
s = size / self.upm
ds, pen_x = [], x
for ch in text:
gname = self.cmap[ord(ch)]
pen = SVGPathPen(self.gs)
self.gs[gname].draw(TransformPen(pen, (s, 0, 0, -s, pen_x, baseline)))
ds.append(pen.getCommands())
pen_x += self.hmtx[gname][0] * s + tracking * size
return " ".join(ds), pen_x - x - tracking * size
def cap_height(self, size: float) -> float:
return float(getattr(self.font["OS/2"], "sCapHeight", 714)) * size / self.upm
WM = Wordmark(FONTS / "NotoSans-SemiBold.ttf")
def wordmark_body(scheme: str, x: float, baseline: float, size: float) -> tuple[str, float]:
d1, w1 = WM.paths("Simurg", size, x, baseline, tracking=0.005)
d2, w2 = WM.paths("Array", size, x + w1 + size * 0.03, baseline, tracking=0.005)
body = f'<path fill="{SCHEMES[scheme]["text"]}" d="{d1}"/><path fill="{ACCENT[scheme]}" d="{d2}"/>'
return body, w1 + size * 0.03 + w2
# ---------------------------------------------------------------- files
def write(path: Path, text: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
return path
def png(svg_path: Path, out: Path, width: int) -> None:
out.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(["rsvg-convert", "-w", str(width), "-o", str(out), str(svg_path)], check=True)
def logos() -> None:
for name, (g, cols, rows) in MARKS.items():
pad = 1
for scheme in SCHEMES:
p = write(OUT / "logo" / f"{name}_{scheme}.svg",
svg(cols + 2 * pad, rows + 2 * pad, mark_svg_body(g, scheme, pad, pad))) # fmt: skip
png(p, OUT / "logo" / "png" / f"{name}_{scheme}.png", 512 if name == "mark" else 256)
size = 100.0
for scheme in SCHEMES:
body, w = wordmark_body(scheme, 0, size * 0.78, size)
p = write(OUT / "logo" / f"wordmark_{scheme}.svg", svg(w + 4, size, body))
png(p, OUT / "logo" / "png" / f"wordmark_{scheme}.png", 900)
g, cols, rows = MARKS["mark"]
for scheme in ("dark", "light", "black", "white"):
bg = SCHEMES[scheme]["bg"]
# horizontal: mark 120 px high, wordmark cap height ~ 40 % of it
cell = 120 / rows
ms = mark_svg_body(g, scheme, 40, 40, cell)
wsize = 64.0
wb, ww = wordmark_body(scheme, 40 + cols * cell + 36, 40 + 60 + WM.cap_height(wsize) / 2, wsize)
W, H = 40 + cols * cell + 36 + ww + 48, 200
p = write(OUT / "logo" / f"lockup_horizontal_{scheme}.svg", svg(W, H, ms + wb, bg))
png(p, OUT / "logo" / "png" / f"lockup_horizontal_{scheme}.png", 1200)
# stacked
cell = 200 / rows
wsize = 60.0
_, ww = wordmark_body(scheme, 0, 0, wsize)
W = max(cols * cell, ww) + 80
ms = mark_svg_body(g, scheme, (W - cols * cell) / 2, 40, cell)
wb, _ = wordmark_body(scheme, (W - ww) / 2, 40 + rows * cell + 30 + WM.cap_height(wsize), wsize)
p = write(OUT / "logo" / f"lockup_stacked_{scheme}.svg", svg(W, 40 + rows * cell + 30 + 90, ms + wb, bg))
png(p, OUT / "logo" / "png" / f"lockup_stacked_{scheme}.png", 800)
def icons() -> None:
"""Rounded indigo tile; micro mark at 16, simple at 32/48, detailed from 180 up."""
d = OUT / "icons"
for size, name in ((16, "mark_micro"), (32, "mark_simple"), (48, "mark_simple"), (180, "mark"), (512, "mark")):
g, cols, rows = MARKS[name]
n = max(cols, rows) + (1 if size <= 48 else 3)
rx = n * 0.18
body = f'<rect width="{n}" height="{n}" rx="{rx:.2f}" fill="{C["indigo"]}"/>'
body += mark_svg_body(g, "dark", (n - cols) / 2, (n - rows) / 2, 1.0, 0.42 if size <= 48 else 0.4)
p = write(d / f"icon_{size}.svg", svg(n, n, body))
png(p, d / f"icon_{size}.png", size)
subprocess.run(["magick", str(d / "icon_16.png"), str(d / "icon_32.png"), str(d / "icon_48.png"),
str(d / "favicon.ico")], check=True) # fmt: skip
for f in d.glob("icon_*.svg"):
if f.stem not in ("icon_16", "icon_512"):
f.unlink()
# ---------------------------------------------------------------- raster templates (Pillow)
def rgb(h: str) -> tuple[int, int, int]:
return int(h[1:3], 16), int(h[3:5], 16), int(h[5:7], 16)
def canvas(w: int, h: int, ss: int = 2) -> Image.Image:
img = Image.new("RGB", (w * ss, h * ss), rgb(C["indigo"]))
dr = ImageDraw.Draw(img)
a, b = rgb(C["indigo"]), rgb(C["indigo_deep"])
for y in range(h * ss):
t = y / (h * ss)
dr.line([(0, y), (w * ss, y)], fill=tuple(int(x + (z - x) * t) for x, z in zip(a, b, strict=True)))
step, k = 18 * ss, 0
for gy in range(step // 2, h * ss, step): # faint bead grid, alternating channels
for gx in range(step // 2, w * ss, step):
k += 1
col = rgb(C["red"] if (gx // step + gy // step) % 2 else C["green"])
c = tuple(int(bg + (cc - bg) * 0.14) for bg, cc in zip(a, col, strict=True))
r = 1.6 * ss
dr.ellipse([gx - r, gy - r, gx + r, gy + r], fill=c)
return img
def draw_mark(img: Image.Image, grid: Grid, x: float, y: float, cell: float, scheme: str = "dark",
rad: float = 0.4) -> None: # fmt: skip
dr = ImageDraw.Draw(img)
col = SCHEMES[scheme]
for (c, r), layer in grid.items():
cx, cy = x + (c + 0.5) * cell, y + (r + 0.5) * cell
dr.ellipse([cx - rad * cell, cy - rad * cell, cx + rad * cell, cy + rad * cell], fill=rgb(col[layer]))
def font(name: str, px: float) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(FONTS / name), int(px))
def text_wordmark(img: Image.Image, x: float, y: float, px: float) -> float:
dr = ImageDraw.Draw(img)
f = font("NotoSans-SemiBold.ttf", px)
dr.text((x, y), "Simurg", font=f, fill=rgb(C["paper"]))
w = dr.textlength("Simurg", font=f) + px * 0.03
dr.text((x + w, y), "Array", font=f, fill=rgb(C["gold"]))
return w + dr.textlength("Array", font=f)
def save(img: Image.Image, w: int, h: int, path: Path, quality: int = 86) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
img = img.resize((w, h), Image.Resampling.LANCZOS)
if path.suffix == ".jpg":
img.save(path, "JPEG", quality=quality, optimize=True, progressive=True)
else:
img.save(path, "PNG", optimize=True)
def templates() -> None:
ss = 2
g, cols, rows = MARKS["mark"]
t = OUT / "templates"
# README banner 1280 x 320
img = canvas(1280, 320, ss)
cell = 230 * ss / rows
draw_mark(img, g, 70 * ss, 45 * ss, cell)
x = 70 * ss + cols * cell + 50 * ss
text_wordmark(img, x, 88 * ss, 76 * ss)
ImageDraw.Draw(img).text((x + 4 * ss, 196 * ss), "Thirty birds, one picture.", font=font("NotoSans-Regular.ttf",
30 * ss), fill=rgb(C["muted_dark"])) # fmt: skip
save(img, 1280, 320, t / "readme_banner_1280x320.png")
# repository social preview 1280 x 640
img = canvas(1280, 640, ss)
cell = 380 * ss / rows
draw_mark(img, g, 90 * ss, 110 * ss, cell)
x = 90 * ss + cols * cell + 60 * ss
text_wordmark(img, x, 200 * ss, 84 * ss)
dr = ImageDraw.Draw(img)
dr.text((x + 4 * ss, 320 * ss), "Thirty birds, one picture.", font=font("NotoSans-Regular.ttf", 34 * ss),
fill=rgb(C["muted_dark"])) # fmt: skip
dr.text((x + 4 * ss, 400 * ss), "Open Infinium array analysis:\nIDAT to genotypes, QC, CN and PGx",
font=font("NotoSansMono-Regular.ttf", 24 * ss), fill=rgb(C["gold"]), spacing=10 * ss) # fmt: skip
save(img, 1280, 640, t / "social_preview_1280x640.png")
# avatar 512, safe inside a circle (mark within 70 % of the diameter)
img = Image.new("RGB", (512 * ss, 512 * ss), rgb(C["indigo"]))
cell = 512 * ss * 0.62 / max(cols, rows)
draw_mark(img, g, (512 * ss - cols * cell) / 2, (512 * ss - rows * cell) / 2 + 6 * ss, cell)
save(img, 512, 512, t / "avatar_512.png")
# WordPress featured image template: title area left, mark right
img = canvas(1200, 630, ss)
cell = 400 * ss / rows
draw_mark(img, g, 1200 * ss - cols * cell - 70 * ss, 100 * ss, cell)
dr = ImageDraw.Draw(img)
dr.text((70 * ss, 88 * ss), "PART N", font=font("NotoSansMono-Regular.ttf", 26 * ss), fill=rgb(C["gold"]))
dr.multiline_text((70 * ss, 140 * ss), "Post title in\ntwo or three lines", font=font("NotoSans-Bold.ttf", 58 * ss),
fill=rgb(C["paper"]), spacing=14 * ss) # fmt: skip
footer(img, 70 * ss, 530 * ss, ss)
save(img, 1200, 630, t / "featured_template_1200x630.jpg")
def footer(img: Image.Image, x: float, y: float, ss: int) -> None:
dr = ImageDraw.Draw(img)
for i, k in enumerate(("red", "green", "gold")):
dr.ellipse([x + i * 18 * ss, y + 10 * ss, x + i * 18 * ss + 12 * ss, y + 22 * ss], fill=rgb(C[k]))
f = font("NotoSans-Regular.ttf", 24 * ss)
dr.text((x + 66 * ss, y), "SimurgArray", font=f, fill=rgb(C["paper"]))
dr.text((x + 66 * ss + dr.textlength("SimurgArray ", font=f), y), "Thirty birds, one picture.", font=f,
fill=rgb(C["muted_dark"])) # fmt: skip
def report_mark() -> None:
g, cols, rows = MARKS["mark_simple"]
body = mark_svg_body(g, "dark", 1, 1)
write(OUT / "templates" / "report_corner_mark.svg", svg(cols + 2, rows + 2, body))
style = f"display:flex;align-items:center;gap:8px;font:12px 'Noto Sans Mono',monospace;color:{C['muted_dark']}"
write(OUT / "templates" / "report_corner_mark.html", f"""<!-- SimurgArray report corner mark.
Always shown with the software version and the run manifest checksum. -->
<div class="simurg-mark" style="{style}">
<img src="report_corner_mark.svg" width="26" height="24" alt="SimurgArray">
<span>SimurgArray {{version}} · run {{manifest_sha256[:12]}}</span>
</div>
""") # fmt: skip
CLI_BANNER = r"""
o o SimurgArray {version}
oo o oo Thirty birds, one picture.
ooooooo
ooo IDAT to genotypes, QC, CN, PGx
/ | \ research use only
* o *
"""
# ---------------------------------------------------------------- checks
def _lin(c: float) -> float:
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
def _unlin(c: float) -> int:
c = max(0.0, min(1.0, c))
return round(255 * (12.92 * c if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055))
def contrast(a: str, b: str) -> float:
def lum(h: str) -> float:
r, g, bb = (_lin(v / 255) for v in rgb(h))
return 0.2126 * r + 0.7152 * g + 0.0722 * bb
la, lb = sorted((lum(a), lum(b)), reverse=True)
return (la + 0.05) / (lb + 0.05)
MACHADO = { # Machado, Oliveira and Fernandes 2009, severity 1.0 (linear RGB)
"protanopia": ((0.152286, 1.052583, -0.204868), (0.114503, 0.786281, 0.099216), (-0.003882, -0.048116, 1.051998)),
"deuteranopia": ((0.367322, 0.860646, -0.227968), (0.280085, 0.672501, 0.047413),
(-0.011820, 0.042940, 0.968881)),
} # fmt: skip
def simulate(img: Image.Image, kind: str) -> Image.Image:
m = MACHADO[kind]
lut = [_lin(i / 255) for i in range(256)]
px = img.convert("RGB").load()
out = img.convert("RGB").copy()
po = out.load()
cache: dict[tuple[int, int, int], tuple[int, int, int]] = {}
for y in range(img.height):
for x in range(img.width):
v = px[x, y]
if v not in cache:
r, g, b = (lut[c] for c in v)
cache[v] = tuple(_unlin(row[0] * r + row[1] * g + row[2] * b) for row in m) # type: ignore[assignment]
po[x, y] = cache[v]
return out
def chart_example(ss: int = 2) -> Image.Image:
"""A genotype cluster plot in the chart palette: colour plus marker shape."""
import random
rng = random.Random(7)
w, h = 420, 300
img = Image.new("RGB", (w * ss, h * ss), rgb(C["paper"]))
dr = ImageDraw.Draw(img)
dr.line([(40 * ss, 260 * ss), (400 * ss, 260 * ss)], fill=rgb(C["muted_light"]), width=ss)
dr.line([(40 * ss, 20 * ss), (40 * ss, 260 * ss)], fill=rgb(C["muted_light"]), width=ss)
for gt, cx in (("AA", 90), ("AB", 220), ("BB", 350)):
col, shape = CHART[gt]
for _ in range(40):
x, y = (cx + rng.gauss(0, 14)) * ss, (140 + rng.gauss(0, 30)) * ss
r = 5 * ss
if shape == "circle":
dr.ellipse([x - r, y - r, x + r, y + r], fill=rgb(col))
elif shape == "square":
dr.rectangle([x - r * 0.9, y - r * 0.9, x + r * 0.9, y + r * 0.9], fill=rgb(col))
else:
dr.polygon([(x, y - r * 1.1), (x - r, y + r * 0.8), (x + r, y + r * 0.8)], fill=rgb(col))
dr.text((cx * ss - 12 * ss, 268 * ss), gt, font=font("NotoSansMono-Regular.ttf", 16 * ss),
fill=rgb(C["indigo"])) # fmt: skip
return img.resize((w, h), Image.Resampling.LANCZOS)
def checks() -> list[tuple[str, str, str, float]]:
d = OUT / "checks"
d.mkdir(parents=True, exist_ok=True)
g, cols, rows = MARKS["mark"]
logo = Image.new("RGB", (cols * 12 + 24, rows * 12 + 24), rgb(C["indigo"]))
draw_mark(logo, g, 12, 12, 12)
chart = chart_example()
panels = [("original", logo, chart)] + [(k, simulate(logo, k), simulate(chart, k)) for k in MACHADO]
pw = logo.width + chart.width + 30
ph = max(logo.height, chart.height) + 50
sheet = Image.new("RGB", (pw, ph * 3), rgb(C["paper"]))
dr = ImageDraw.Draw(sheet)
for i, (name, lg, ch) in enumerate(panels):
y = i * ph
dr.text((10, y + 8), name, font=font("NotoSansMono-Regular.ttf", 18), fill=rgb(C["indigo"]))
sheet.paste(lg, (10, y + 36))
sheet.paste(ch, (logo.width + 20, y + 36))
sheet.save(d / "colour_vision_check.png", optimize=True)
# small sizes side by side
row = Image.new("RGB", (520, 90), rgb(C["paper"]))
x = 10
for s in (16, 32, 48, 180):
ic = Image.open(OUT / "icons" / f"icon_{s}.png").convert("RGB")
if s == 180:
ic = ic.resize((72, 72), Image.Resampling.LANCZOS)
row.paste(ic, (x, 10))
x += ic.width + 30
row.save(d / "icon_sizes.png", optimize=True)
pairs = [("paper", "indigo", "text on dark"), ("muted_dark", "indigo", "secondary text on dark"),
("gold", "indigo", "accent text on dark"), ("indigo", "paper", "text on light"),
("muted_light", "paper", "secondary text on light"), ("gold_ink", "paper", "accent text on light"),
("red", "indigo", "graphics on dark"), ("green", "indigo", "graphics on dark"),
("gold_deep", "paper", "graphics on light"), ("red", "paper", "graphics on light"),
("green", "paper", "graphics on light"), ("gold", "paper", "never for text or small marks")] # fmt: skip
return [(a, b, use, contrast(C[a], C[b])) for a, b, use in pairs]
def family(featured: list[Path]) -> None:
"""Featured images and templates side by side (acceptance check 4)."""
tiles = [Image.open(p).convert("RGB") for p in featured] + [
Image.open(OUT / "templates" / "featured_template_1200x630.jpg").convert("RGB"),
Image.open(OUT / "templates" / "social_preview_1280x640.png").convert("RGB"),
Image.open(OUT / "templates" / "readme_banner_1280x320.png").convert("RGB"),
Image.open(OUT / "templates" / "avatar_512.png").convert("RGB"),
] # fmt: skip
th = 200
tiles = [t.resize((int(t.width * th / t.height), th), Image.Resampling.LANCZOS) for t in tiles]
per_row, gap = 3, 16
rows_ = [tiles[i : i + per_row] for i in range(0, len(tiles), per_row)]
W = max(sum(t.width for t in r) + gap * (len(r) + 1) for r in rows_)
sheet = Image.new("RGB", (W, len(rows_) * (th + gap) + gap), rgb(C["paper"]))
y = gap
for r in rows_:
x = gap
for t in r:
sheet.paste(t, (x, y))
x += t.width + gap
y += th + gap
sheet.save(OUT / "checks" / "family.jpg", quality=76, optimize=True)
def main() -> None:
logos()
icons()
templates()
report_mark()
write(OUT / "templates" / "cli_banner.txt", CLI_BANNER.lstrip("\n"))
table = checks()
featured = sorted((ROOT / "blog" / "images").glob("*.jpg"))
family(featured)
lines = ["| colour | on | use | contrast | AA text (4.5) | graphics (3.0) |", "|---|---|---|---|---|---|"]
for a, b, use, cr in table:
lines.append(f"| {a} {C[a]} | {b} {C[b]} | {use} | {cr:.2f} | {'yes' if cr >= 4.5 else 'no'} | "
f"{'yes' if cr >= 3 else 'no'} |") # fmt: skip
write(OUT / "checks" / "contrast.md", "\n".join(lines) + "\n")
print("\n".join(lines))
if __name__ == "__main__":
main()
+86
View File
@@ -0,0 +1,86 @@
"""Geometry of the SimurgArray mark on its dot grid (no dependencies beyond the standard library).
A frontal simurg with raised wings (three feathers each), a three-dot crown and three tail plumes: red channel,
gold, green channel, each ending in a peacock eye. Shapes are capsules, circles and an ellipse in grid units; a cell
belongs to the first shape that contains its centre. ``SIMPLE`` and ``MICRO`` are hand-drawn small versions.
"""
from __future__ import annotations
import math
from typing import Any
def _seg(px: float, py: float, a: tuple[float, float], b: tuple[float, float]) -> float:
dx, dy = b[0] - a[0], b[1] - a[1]
t = max(0.0, min(1.0, ((px - a[0]) * dx + (py - a[1]) * dy) / ((dx * dx + dy * dy) or 1.0)))
return math.hypot(px - a[0] - t * dx, py - a[1] - t * dy)
CX = 15.5
_WING = [([(13.6, 9.6), (8, 5.2), (1.8, 1.4)], 1.25), ([(13.4, 11), (7.5, 8.4), (1.2, 6.6)], 1.25),
([(13.4, 12.6), (8, 12), (2.6, 11.8)], 1.2)] # fmt: skip
SHAPES: list[tuple[str, Any, str]] = []
for pts, r in _WING:
SHAPES.append(("cap", (pts, r), "gold"))
SHAPES.append(("cap", ([(2 * CX - x, y) for x, y in pts], r), "gold"))
SHAPES += [
("circ", (CX, 4.4, 2.3), "gold"), ("cap", ([(CX, 6), (CX, 8)], 1.3), "gold"), # head, neck
("circ", (CX, 1.2, 0.62), "gold"), ("circ", (13.6, 1.9, 0.62), "gold"), ("circ", (17.4, 1.9, 0.62), "gold"),
("ell", (CX, 12.5, 2.9, 5.4), "gold"), # body
("cap", ([(14.8, 17), (12.6, 20.5), (10.4, 23.2)], 0.7), "red"), ("circ", (10, 24.3, 1.6), "red"),
("cap", ([(CX, 17.5), (CX, 23.2)], 0.7), "gold"), ("circ", (CX, 24.6, 1.6), "gold"),
("cap", ([(16.2, 17), (18.4, 20.5), (20.6, 23.2)], 0.7), "green"), ("circ", (21, 24.3, 1.6), "green"),
] # fmt: skip
DETAILED_SIZE = (31, 27)
SIMPLE = """
X.........X
XX.......XX
.XX..X..XX.
..XXXXXXX..
XXXXXXXXXXX
...XXXXX...
....XXX....
...R.X.G...
..R..X..G..
.RR.XXX.GG.
"""
MICRO = """
X.....X
XX.X.XX
.XXXXX.
..XXX..
...X...
.R.X.G.
R..X..G
"""
Grid = dict[tuple[int, int], str]
def _inside(x: float, y: float, kind: str, p: Any) -> bool:
if kind == "circ":
return math.hypot(x - p[0], y - p[1]) <= p[2]
if kind == "cap":
pts, r = p
return min(_seg(x, y, pts[i], pts[i + 1]) for i in range(len(pts) - 1)) <= r
cx, cy, rx, ry = p
return ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 <= 1
def detailed() -> tuple[Grid, int, int]:
cols, rows = DETAILED_SIZE
g: Grid = {}
for r in range(rows):
for c in range(cols):
for kind, p, layer in SHAPES:
if _inside(c + 0.5, r + 0.5, kind, p):
g[(c, r)] = layer
break
return g, cols, rows
def art(s: str) -> tuple[Grid, int, int]:
lines = s.strip().splitlines()
g = {(c, r): {"X": "gold", "R": "red", "G": "green"}[ch] for r, ln in enumerate(lines) for c, ch in enumerate(ln)
if ch != "."} # fmt: skip
return g, len(lines[0]), len(lines)