codex/past-exam-of-the-mathematics-course-of-the-university-of-cambridge/2003/ib/paper-3-dodecahedron.py

past-exam-of-the-mathematics-course-of-the-university-of-cambridge/2003/ib/paper-3-dodecahedron.py
"""Draw a regular dodecahedron and its (2,3,5) spherical reflection chamber.

Tested with Python 3.14.4, NumPy 2.3.5 and Matplotlib 3.10.7.
Writes paper-3-dodecahedron.png to the caller's current working directory.
The caller may supply MPLCONFIGDIR; this script leaves it unchanged.
"""
import itertools

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from mpl_toolkits.mplot3d.art3d import Poly3DCollection


def main():
    phi = (1 + np.sqrt(5.0)) / 2
    vertices = list(itertools.product([-1.0, 1.0], repeat=3))
    normals = []
    for u, v in itertools.product([-1.0, 1.0], repeat=2):
        a = (0, u / phi, v * phi)
        vertices.extend([a, (a[1], a[2], 0), (a[2], 0, a[1])])
        n = (0, u * phi, v)
        normals.extend([n, (n[1], n[2], 0), (n[2], 0, n[1])])
    vertices = np.array(vertices)
    polygons = []
    for normal in normals:
        normal = np.array(normal)
        face = vertices[np.isclose(vertices @ normal, phi ** 2)]
        center = face.mean(axis=0)
        e1 = face[0] - center
        e1 /= np.linalg.norm(e1)
        e2 = np.cross(normal / np.linalg.norm(normal), e1)
        angles = np.arctan2((face - center) @ e2, (face - center) @ e1)
        polygons.append(face[np.argsort(angles)])
    normal = np.array([0., phi, 1.])
    F = normal * phi ** 2 / np.dot(normal, normal)
    M = np.array([0., phi, 0.])
    V = np.array([1 / phi, phi, 0.])
    chamber = np.array([F, M, V])
    fig = plt.figure(figsize=(10, 5), facecolor="white", layout="constrained")
    ax = fig.add_subplot(121, projection="3d", computed_zorder=False)
    colors = ["#ffdf8d" if np.allclose(n, normal) else "#d9e7ed" for n in normals]
    ax.add_collection3d(Poly3DCollection(polygons, facecolors=colors, edgecolors="#405367", linewidths=.65, alpha=1, zorder=1))
    ax.add_collection3d(Poly3DCollection([chamber], facecolors="#d55339", edgecolors="#a63625", linewidths=2, alpha=1, zorder=2))
    ax.scatter(*chamber.T, color="#94271d", s=25, depthshade=False, zorder=3)
    for point, name, offset in zip(chamber, ["F", "M", "V"], [[0, 0, .09], [-.24, 0, -.10], [.22, 0, -.10]]):
        ax.text(*(point + offset), name, fontsize=12, weight="bold", zorder=4)
    ax.set_title("Regular dodecahedron\nFace center F, edge midpoint M, vertex V", fontsize=11)
    ax.set_xlim(-1.8, 1.8); ax.set_ylim(-1.8, 1.8); ax.set_zlim(-1.8, 1.8)
    ax.set_box_aspect((1, 1, 1)); ax.view_init(elev=22, azim=55); ax.set_axis_off()
    bx = fig.add_subplot(122, projection="3d")
    unit = chamber / np.linalg.norm(chamber, axis=1)[:, None]
    for a, b in [(0, 1), (1, 2), (2, 0)]:
        angle = np.arccos(np.clip(np.dot(unit[a], unit[b]), -1, 1))
        s = np.linspace(0, 1, 100)
        arc = (np.sin((1 - s) * angle)[:, None] * unit[a] + np.sin(s * angle)[:, None] * unit[b]) / np.sin(angle)
        bx.plot(*arc.T, color="#b63624", linewidth=2.6)
    # A small patch of the sphere contains the entire chamber.
    longitude = np.linspace(0.48, 1.7, 20)
    latitude = np.linspace(-.1, .65, 16)
    lon, lat = np.meshgrid(longitude, latitude)
    bx.plot_wireframe(np.cos(lat)*np.cos(lon), np.cos(lat)*np.sin(lon), np.sin(lat), color="#bdcbd3", linewidth=.45, alpha=.65)
    bx.scatter(*unit.T, color="#94271d", s=30, depthshade=False)
    for point, name in zip(unit, [r"F: $\pi/5$", r"M: $\pi/2$", r"V: $\pi/3$"]):
        bx.text(*(point * 1.035), name, fontsize=11, weight="bold")
    bx.set_xlim(-.15, .65); bx.set_ylim(.6, 1.1); bx.set_zlim(-.08, .65)
    bx.set_box_aspect((.8, .5, .73)); bx.view_init(elev=22, azim=55); bx.set_axis_off()
    bx.set_title("Radial projection to the unit sphere\nReflection chamber of angles (π/2, π/3, π/5)", fontsize=11)
    fig.savefig("paper-3-dodecahedron.png", dpi=130, facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()