Files
CST-agent/scripts/build_hermit_font.py
T
WHCandCursor 41f4cf85ee
ci / tcc-check (push) Failing after 40s
ci / linux (aarch64, 0) (push) Skipped
ci / linux (x86, 1) (push) Skipped
ci / linux (x86_64, 1) (push) Skipped
ci / windows (push) Skipped
release: Hermit 1.0.0 — home layout, icon font, install
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>
2026-10-29 21:57:46 +08:00

233 lines
7.7 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())