Compact the home parliament chip, animate crab states like the HTML kit, ship hermit-icons.ttf (sbix) + frames, and install the font from make/ bootstrap/package paths. Co-authored-by: Cursor <cursoragent@cursor.com>
233 lines
7.7 KiB
Python
Executable File
233 lines
7.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Bake Hermit animation frames (HTML-kit states) into PNG + sbix color TTF.
|
||
|
||
Geometry/colors follow icons/agent-motion. Animation mirrors the HTML kit:
|
||
idle breath/blink, ask tilt+?, thinking look+claw, coding >_, success smile,
|
||
error mark — as 8 discrete frames per state × bare/shell.
|
||
|
||
Outputs:
|
||
res/hermit-frames/{bare,shell}/{state}/fNN.png
|
||
res/hermit-icons.ttf
|
||
.gen/hermit_font_map.inc
|
||
|
||
nix-shell -p python3Packages.pillow python3Packages.fonttools --run \\
|
||
'python3 scripts/build_hermit_font.py'
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
OUT_FRAMES = ROOT / "res" / "hermit-frames"
|
||
OUT_TTF = ROOT / "res" / "hermit-icons.ttf"
|
||
OUT_INC = ROOT / ".gen" / "hermit_font_map.inc"
|
||
|
||
STATES = ["idle", "question", "thinking", "coding", "success", "error"]
|
||
N_FRAME = 8
|
||
PUA_BASE = 0xE700
|
||
SIZE = 64
|
||
|
||
SHELL = (0x27, 0x7F, 0x94, 255)
|
||
PROMPT = (0xA9, 0xD9, 0xD7, 255)
|
||
BODY = (0xF6, 0x80, 0x42, 255)
|
||
EYE = (0x99, 0x57, 0xAD, 255)
|
||
LEG = (0xA4, 0x57, 0x4F, 255)
|
||
MARK = (0xE9, 0xBA, 0x47, 255)
|
||
TRANS = (0, 0, 0, 0)
|
||
|
||
|
||
def codepoint(shell: int, state: int, frame: int) -> int:
|
||
return PUA_BASE + (shell * len(STATES) + state) * N_FRAME + frame
|
||
|
||
|
||
def draw_icon(state: str, frame: int, with_shell: bool):
|
||
from PIL import Image, ImageDraw
|
||
|
||
im = Image.new("RGBA", (SIZE, SIZE), TRANS)
|
||
d = ImageDraw.Draw(im)
|
||
phase = frame * (math.pi * 2 / N_FRAME)
|
||
t = frame / max(N_FRAME - 1, 1)
|
||
|
||
cx, cy = (40, 38) if with_shell else (32, 30)
|
||
breath = math.sin(phase) * 1.2
|
||
rot = 0.0
|
||
if state == "question":
|
||
rot = 6 * math.sin(phase * 0.5)
|
||
elif state == "coding":
|
||
rot = 1.4 * math.sin(phase * 2)
|
||
elif state == "error":
|
||
rot = 3 * math.sin(phase * 3) if 0.7 < t < 0.95 else 0
|
||
elif state == "success":
|
||
breath -= abs(math.sin(phase)) * 2.0
|
||
elif state == "thinking":
|
||
rot = -2
|
||
|
||
if with_shell:
|
||
d.rounded_rectangle([4, 8, 52, 44], radius=3, fill=SHELL)
|
||
d.line([(12, 18), (16, 22), (12, 26)], fill=PROMPT, width=2)
|
||
d.line([(22, 27), (30, 27)], fill=PROMPT, width=2)
|
||
|
||
ly = cy + 14 + breath * 0.3
|
||
d.line([(cx - 9, ly), (cx - 9, ly + 6)], fill=LEG, width=3)
|
||
d.line([(cx + 9, ly), (cx + 9, ly + 6)], fill=LEG, width=3)
|
||
|
||
bw, bh = 22, 15
|
||
bx0 = cx - bw // 2
|
||
by0 = int(cy - bh // 2 + breath)
|
||
shift = int(rot * 0.3)
|
||
d.rounded_rectangle(
|
||
[bx0 + shift, by0, bx0 + bw + shift, by0 + bh], radius=3, fill=BODY
|
||
)
|
||
|
||
blink = state in ("idle", "question") and 3 <= frame <= 4
|
||
ey = by0 + (7 if not blink else 9)
|
||
eh = 1 if blink else 4
|
||
bx0s = bx0 + shift
|
||
|
||
if state == "coding":
|
||
d.line([(bx0s + 4, ey), (bx0s + 7, ey + 2), (bx0s + 4, ey + 4)], fill=EYE, width=2)
|
||
d.line([(bx0s + 12, ey + 3), (bx0s + 17, ey + 3)], fill=EYE, width=2)
|
||
elif state == "success":
|
||
d.arc([bx0s + 4, ey, bx0s + 9, ey + 5], 200, 340, fill=EYE, width=2)
|
||
d.arc([bx0s + 12, ey, bx0s + 17, ey + 5], 200, 340, fill=EYE, width=2)
|
||
elif state == "error":
|
||
d.line([(bx0s + 4, ey), (bx0s + 8, ey + 2)], fill=EYE, width=2)
|
||
d.line([(bx0s + 14, ey + 2), (bx0s + 18, ey)], fill=EYE, width=2)
|
||
elif state == "thinking":
|
||
look = 1 if frame % 4 >= 2 else 0
|
||
d.line([(bx0s + 6 + look, ey), (bx0s + 6 + look, ey + eh)], fill=EYE, width=2)
|
||
d.line([(bx0s + 14 + look, ey), (bx0s + 14 + look, ey + eh)], fill=EYE, width=2)
|
||
else:
|
||
d.line([(bx0s + 6, ey), (bx0s + 6, ey + eh)], fill=EYE, width=2)
|
||
d.line([(bx0s + 14, ey), (bx0s + 14, ey + eh)], fill=EYE, width=2)
|
||
|
||
if state == "question":
|
||
d.arc([bx0s + bw - 2, by0 - 10, bx0s + bw + 8, by0], 200, 40, fill=EYE, width=2)
|
||
d.point((bx0s + bw + 3, by0 + 2), fill=EYE)
|
||
elif state == "thinking":
|
||
d.line([(bx0s + bw - 2, by0 + bh - 2), (bx0s + bw - 2, by0 + bh + 5)], fill=MARK, width=3)
|
||
d.line([(bx0s + bw - 2, by0 + bh + 5), (bx0s + bw - 8, by0 + bh + 5)], fill=MARK, width=3)
|
||
elif state == "error":
|
||
d.line([(bx0s + bw + 2, by0 - 6), (bx0s + bw + 2, by0)], fill=EYE, width=2)
|
||
d.point((bx0s + bw + 2, by0 + 4), fill=EYE)
|
||
|
||
return im
|
||
|
||
|
||
def write_map_inc() -> None:
|
||
OUT_INC.parent.mkdir(parents=True, exist_ok=True)
|
||
lines = [
|
||
"/* Generated by scripts/build_hermit_font.py — do not edit. */",
|
||
f"#define HERMIT_FONT_PUA_BASE_GEN 0x{PUA_BASE:X}",
|
||
f"#define HERMIT_FONT_FRAMES_GEN {N_FRAME}",
|
||
f"#define HERMIT_FONT_STATES_GEN {len(STATES)}",
|
||
"static const char *const HERMIT_FONT_STATE_NAMES[] = {",
|
||
]
|
||
for s in STATES:
|
||
lines.append(f' "{s}",')
|
||
lines.append("};")
|
||
OUT_INC.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
print(f"wrote {OUT_INC}")
|
||
|
||
|
||
def build_sbix_font(images: dict[int, object]) -> bool:
|
||
try:
|
||
from fontTools.fontBuilder import FontBuilder
|
||
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
||
from fontTools.ttLib import newTable
|
||
from fontTools.ttLib.tables.sbixGlyph import Glyph as SbixGlyph
|
||
from fontTools.ttLib.tables.sbixStrike import Strike
|
||
except Exception as e:
|
||
print(f"fontTools missing: {e}", file=sys.stderr)
|
||
return False
|
||
|
||
cps = sorted(images)
|
||
glyph_order = [".notdef"] + [f"u{cp:04X}" for cp in cps]
|
||
cmap = {cp: f"u{cp:04X}" for cp in cps}
|
||
|
||
fb = FontBuilder(SIZE, isTTF=True)
|
||
fb.setupGlyphOrder(glyph_order)
|
||
fb.setupCharacterMap(cmap)
|
||
pen = TTGlyphPen(None)
|
||
empty = pen.glyph()
|
||
fb.setupGlyf({n: empty for n in glyph_order})
|
||
fb.setupHorizontalMetrics({n: (SIZE, 0) for n in glyph_order})
|
||
fb.setupHorizontalHeader(ascent=SIZE, descent=0)
|
||
fb.setupNameTable(
|
||
{
|
||
"familyName": "Hermit Icons",
|
||
"styleName": "Regular",
|
||
"uniqueFontIdentifier": "Hermit Icons 1.0",
|
||
"fullName": "Hermit Icons",
|
||
"psName": "HermitIcons",
|
||
"version": "Version 1.000",
|
||
}
|
||
)
|
||
fb.setupOS2(sTypoAscender=SIZE, sTypoDescender=0, usWinAscent=SIZE, usWinDescent=0)
|
||
fb.setupPost()
|
||
font = fb.font
|
||
|
||
import io
|
||
|
||
strike = Strike(ppem=SIZE, resolution=72)
|
||
strike.glyphs = {}
|
||
for name in glyph_order:
|
||
if name == ".notdef":
|
||
continue
|
||
cp = int(name[1:], 16)
|
||
im = images[cp]
|
||
bio = io.BytesIO()
|
||
im.save(bio, format="PNG")
|
||
g = SbixGlyph()
|
||
g.glyphName = name
|
||
g.originOffsetX = 0
|
||
g.originOffsetY = 0
|
||
g.graphicType = "png "
|
||
g.imageData = bio.getvalue()
|
||
strike.glyphs[name] = g
|
||
|
||
sbix = newTable("sbix")
|
||
sbix.version = 1
|
||
sbix.flags = 1
|
||
sbix.strikes = {SIZE: strike}
|
||
font["sbix"] = sbix
|
||
|
||
OUT_TTF.parent.mkdir(parents=True, exist_ok=True)
|
||
font.save(str(OUT_TTF))
|
||
print(f"wrote {OUT_TTF} (sbix, {len(images)} glyphs)")
|
||
return True
|
||
|
||
|
||
def main() -> int:
|
||
try:
|
||
from PIL import Image # noqa: F401
|
||
except ImportError:
|
||
print(
|
||
"need Pillow: nix-shell -p python3Packages.pillow python3Packages.fonttools",
|
||
file=sys.stderr,
|
||
)
|
||
return 1
|
||
|
||
write_map_inc()
|
||
images: dict[int, object] = {}
|
||
for shell in (0, 1):
|
||
for si, st in enumerate(STATES):
|
||
d = OUT_FRAMES / ("shell" if shell else "bare") / st
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
for fr in range(N_FRAME):
|
||
im = draw_icon(st, fr, with_shell=bool(shell))
|
||
im.save(d / f"f{fr:02d}.png")
|
||
images[codepoint(shell, si, fr)] = im
|
||
print(f"wrote {len(images)} PNGs under {OUT_FRAMES}")
|
||
|
||
if not build_sbix_font(images):
|
||
print("TTF not written — PNGs ready; install fonttools for the font", file=sys.stderr)
|
||
return 1
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|