#!/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())