from __future__ import annotations import math import shutil import subprocess from pathlib import Path from PIL import Image, ImageDraw, ImageFont ROOT = Path(__file__).resolve().parents[1] OUTPUT_DIR = ROOT / "content" / "drafts" / "animations" SCRATCH = OUTPUT_DIR / "_symmetry_triangle_actions_frames" FFMPEG = ROOT / ".tools" / "micromamba-anim-root" / "envs" / "anim" / "Library" / "bin" / "ffmpeg.exe" if not FFMPEG.is_file(): from imageio_ffmpeg import get_ffmpeg_exe FFMPEG = Path(get_ffmpeg_exe()) WIDTH = 900 HEIGHT = 620 FPS = 18 MOVE = 42 HOLD = 18 BG = (255, 255, 255) INK = (10, 10, 10) REFERENCE = (224, 224, 224) CENTER = (450.0, 245.0) RADIUS = 165.0 def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: candidates = [ "segoeuib.ttf" if bold else "segoeui.ttf", "arialbd.ttf" if bold else "arial.ttf", "DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf", ] for name in candidates: try: return ImageFont.truetype(name, size) except OSError: continue return ImageFont.load_default() FONT_SET = font(30, True) BASE = { "A": (CENTER[0], CENTER[1] - RADIUS), "B": (CENTER[0] - RADIUS * math.cos(math.radians(30)), CENTER[1] + RADIUS * 0.5), "C": (CENTER[0] + RADIUS * math.cos(math.radians(30)), CENTER[1] + RADIUS * 0.5), } ORDER = ["A", "B", "C"] ACTIONS = [ ("e", "identity", None), ("r", "rotate", 120.0), ("r²", "rotate", 240.0), ("s", "flip", "A"), ("sr", "flip_rotate", ("A", 120.0)), ("sr²", "flip_rotate", ("A", 240.0)), ] def ease(t: float) -> float: t = max(0.0, min(1.0, t)) return 0.5 - 0.5 * math.cos(math.pi * t) def rotate_point(point: tuple[float, float], degrees: float) -> tuple[float, float]: theta = math.radians(degrees) x = point[0] - CENTER[0] y = point[1] - CENTER[1] return ( CENTER[0] + math.cos(theta) * x - math.sin(theta) * y, CENTER[1] + math.sin(theta) * x + math.cos(theta) * y, ) def fold_point(point: tuple[float, float], axis_label: str, progress: float) -> tuple[float, float]: axis_point = BASE[axis_label] vx = axis_point[0] - CENTER[0] vy = axis_point[1] - CENTER[1] length = math.hypot(vx, vy) ux = vx / length uy = vy / length px = point[0] - CENTER[0] py = point[1] - CENTER[1] along = px * ux + py * uy perp_x = px - along * ux perp_y = py - along * uy # Projection of a 180 degree flip through the page: perpendicular distance # collapses to zero halfway through, then reappears on the other side. scale = math.cos(math.pi * ease(progress)) return ( CENTER[0] + along * ux + scale * perp_x, CENTER[1] + along * uy + scale * perp_y, ) def positions_for( action_kind: str, parameter: float | str | tuple[str, float] | None, progress: float, ) -> dict[str, tuple[float, float]]: if action_kind == "rotate": degrees = float(parameter) * ease(progress) return {key: rotate_point(point, degrees) for key, point in BASE.items()} if action_kind == "flip": return {key: fold_point(point, str(parameter), progress) for key, point in BASE.items()} if action_kind == "flip_rotate": axis_label, degrees = parameter if isinstance(parameter, tuple) else ("A", 0.0) if progress < 0.5: phase = progress / 0.5 return {key: fold_point(point, axis_label, phase) for key, point in BASE.items()} phase = (progress - 0.5) / 0.5 flipped = {key: fold_point(point, axis_label, 1.0) for key, point in BASE.items()} return {key: rotate_point(point, degrees * ease(phase)) for key, point in flipped.items()} return dict(BASE) def draw_triangle( draw: ImageDraw.ImageDraw, positions: dict[str, tuple[float, float]], color: tuple[int, int, int], width: int, ) -> None: points = [positions[key] for key in ORDER] draw.line(points + [points[0]], fill=color, width=width, joint="curve") def set_text(count: int) -> str: if count <= 0: return "" symbols = [symbol for symbol, _, _ in ACTIONS[:count]] body = ", ".join(symbols) if count == len(ACTIONS): return "{" + body + "} = D₃" return "{" + body + "}" def text_width(draw: ImageDraw.ImageDraw, text: str) -> int: if not text: return 0 box = draw.textbbox((0, 0), text, font=FONT_SET) return box[2] - box[0] def draw_set(draw: ImageDraw.ImageDraw, count: int) -> None: text = set_text(count) if not text: return x = (WIDTH - text_width(draw, text)) / 2 draw.text((x, 500), text, fill=INK, font=FONT_SET) def draw_frame(action_index: int, progress: float, completed_count: int) -> Image.Image: image = Image.new("RGB", (WIDTH, HEIGHT), BG) draw = ImageDraw.Draw(image) symbol, kind, parameter = ACTIONS[action_index] draw_triangle(draw, BASE, REFERENCE, 3) if kind == "identity": width = int(4 + 8 * math.sin(math.pi * ease(progress))) positions = dict(BASE) else: width = 4 positions = positions_for(kind, parameter, progress) draw_triangle(draw, positions, INK, width) draw_set(draw, completed_count) return image def build_frames() -> list[Image.Image]: frames: list[Image.Image] = [] completed = 0 for index, _action in enumerate(ACTIONS): for step in range(MOVE): progress = step / (MOVE - 1) frames.append(draw_frame(index, progress, completed)) completed = index + 1 for _ in range(HOLD): frames.append(draw_frame(index, 1.0, completed)) return frames def main() -> None: frames = build_frames() OUTPUT_DIR.mkdir(parents=True, exist_ok=True) if SCRATCH.exists(): shutil.rmtree(SCRATCH) SCRATCH.mkdir() try: for index, frame in enumerate(frames): frame.save(SCRATCH / f"frame_{index:04d}.png") video = OUTPUT_DIR / "symmetry-triangle-actions.mp4" subprocess.run( [ str(FFMPEG), "-y", "-framerate", str(FPS), "-i", str(SCRATCH / "frame_%04d.png"), "-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(video), ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) poster = OUTPUT_DIR / "symmetry-triangle-actions-poster.png" frames[-1].save(poster) print(video) print(poster) finally: if SCRATCH.exists(): shutil.rmtree(SCRATCH) if __name__ == "__main__": main()