codex/past-exam-of-the-topology-course-of-the-university-of-oxford/2021-polygon-boundaries.py

past-exam-of-the-topology-course-of-the-university-of-oxford/2021-polygon-boundaries.py
"""Generate the 2021 topology polygon explanation in the current directory.

Tested with Python 3.14, NumPy 2.3.5 and Matplotlib 3.10.7.
Run from _media/past-exam-of-the-topology-course-of-the-university-of-oxford.
"""

from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon
import numpy as np


def arrow(ax, start, end, label, offset):
    start, end = np.asarray(start), np.asarray(end)
    mid = (start + end) / 2
    direction = (end - start) / np.linalg.norm(end - start)
    ax.annotate(
        "", xy=mid + 0.16 * direction, xytext=mid - 0.16 * direction,
        arrowprops={"arrowstyle": "->", "color": "#26364b", "lw": 1.7},
    )
    ax.text(*(mid + np.asarray(offset)), label, ha="center", va="center", fontsize=13)


def draw_panel(ax, points, classes, paired, free, title, count):
    points = np.asarray(points, dtype=float)
    ax.add_patch(Polygon(points, closed=True, facecolor="#f0f3f7", edgecolor="#26364b", lw=1.8))
    for start, end, name, offset in paired:
        arrow(ax, points[start], points[end], name, offset)
    for start, end, name in free:
        p, q = points[start], points[end]
        ax.plot([p[0], q[0]], [p[1], q[1]], color="#c26a10", lw=4, solid_capstyle="round")
        mid = (p + q) / 2
        ax.text(mid[0] - 0.18, mid[1] - 0.18, name, color="#8b4908", ha="center", va="center", fontsize=12)
    centroid = points.mean(axis=0)
    for index, (point, name) in enumerate(zip(points, classes)):
        color = "#165eaa" if name == "P" else "#973b8b"
        ax.scatter(*point, c=color, s=38, zorder=4)
        outward = point - centroid
        outward /= np.linalg.norm(outward)
        ax.text(*(point + 0.23 * outward), f"$v_{index}$: {name}", fontsize=10, color=color, ha="center", va="center")
    ax.set_title(title, fontsize=14, pad=12)
    ax.text(1, -0.68, count, ha="center", va="top", fontsize=11, linespacing=1.6)
    ax.set(xlim=(-0.6, 2.6), ylim=(-1.25, 2.55), aspect="equal")
    ax.axis("off")


def main():
    fig, axes = plt.subplots(1, 2, figsize=(9.4, 5.0), facecolor="white")
    draw_panel(
        axes[0], [(0, 2), (2, 2), (2, 0), (1, 0), (0, 1)], ["P"] * 5,
        [(4, 0, "$a$", (-0.18, 0)), (2, 1, "$a$", (0.18, 0)),
         (0, 1, "$b$", (0, 0.2)), (3, 2, "$b$", (0, -0.2))],
        [(3, 4, "$e$")], "3(b)(ii): pentagon",
        r"$V=1,\ E=3,\ F=1,\ \chi=-1$" "\nBoundary: one loop $e$ at $P$",
    )
    draw_panel(
        axes[1], [(0, 2), (1, 2), (2, 1), (2, 0), (1, 0), (0, 1)], ["P", "Q", "P", "Q", "P", "Q"],
        [(5, 0, "$a$", (-0.18, 0)), (3, 2, "$a$", (0.18, 0)),
         (0, 1, "$b$", (0, 0.2)), (4, 3, "$b$", (0, -0.2))],
        [(1, 2, "$e$"), (4, 5, "$f$")], "3(b)(iii): hexagon",
        r"$V=2,\ E=4,\ F=1,\ \chi=-1$" "\nBoundary: $P$ to $Q$ along $e$, back along $f$",
    )
    fig.suptitle("The orange free edges form one boundary circle in each quotient", fontsize=14, y=0.98)
    fig.tight_layout(rect=(0, 0, 1, 0.93))
    fig.savefig(Path("2021-polygon-boundaries.png"), dpi=150, facecolor="white")
    plt.close(fig)


if __name__ == "__main__":
    main()