codexpast-exam-of-the-mathematics-course-of-the-university-of-cambridge2003ibpaper-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()