codexspherical-right-triangle-trigonometry.py
spherical-right-triangle-trigonometry.py
"""Draw a non-octant spherical right triangle; output PNG basename to CWD.
Tested with Python 3.14.4, NumPy 2.3.5, Matplotlib 3.10.7.
Run from _media to regenerate the published image.
"""
from pathlib import Path
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import numpy as np
def arc(p, q, samples=201):
angle = np.arccos(np.clip(p @ q, -1, 1))
t = np.linspace(0, 1, samples)
return (np.sin((1 - t) * angle)[:, None] * p
+ np.sin(t * angle)[:, None] * q) / np.sin(angle)
def main():
vertices = np.array([[0, 0, 1], [np.sqrt(3) / 2, 0, .5],
[0, np.sqrt(3) / 2, .5]])
a, b, c = [np.arccos(vertices[j] @ vertices[k])
for j, k in [(1, 2), (2, 0), (0, 1)]]
angles = []
for i in range(3):
j, k = [v for v in range(3) if v != i]
p = vertices[i]
tangents = [vertices[v] - (p @ vertices[v]) * p for v in [j, k]]
tangents = [t / np.linalg.norm(t) for t in tangents]
angles.append(np.arccos(np.clip(tangents[0] @ tangents[1], -1, 1)))
assert np.allclose([a, b, c], [np.arccos(.25), np.pi / 3, np.pi / 3])
assert np.allclose(angles, [np.pi / 2, np.arccos(1 / np.sqrt(5)),
np.arccos(1 / np.sqrt(5))])
assert np.allclose(np.sin(angles) / np.sin([a, b, c]), 4 / np.sqrt(15))
plt.rcParams.update({'font.size': 12})
fig = plt.figure(figsize=(9.2, 6.5), facecolor='white')
ax = fig.add_axes([0, .06, .64, .83], projection='3d', computed_zorder=False)
ax.set_facecolor('white')
ax.set_proj_type('ortho')
ax.view_init(elev=22, azim=45)
ax.set_box_aspect((1, 1, 1), zoom=1.16)
ax.set(xlim=(-1.12, 1.12), ylim=(-1.12, 1.12), zlim=(-1.12, 1.12))
ax.set_axis_off()
phi, theta = np.meshgrid(np.linspace(0, 2 * np.pi, 85), np.linspace(0, np.pi, 43))
ax.plot_wireframe(np.sin(theta) * np.cos(phi), np.sin(theta) * np.sin(phi),
np.cos(theta), rstride=4, cstride=7, color='#acbdca',
alpha=.35, lw=.65, zorder=1)
# Normalized convex combinations fill exactly the minor spherical triangle.
t, s = np.meshgrid(np.linspace(0, 1, 61), np.linspace(0, 1, 61))
patch = ((1 - t)[..., None] * vertices[0]
+ t[..., None] * ((1 - s)[..., None] * vertices[1]
+ s[..., None] * vertices[2]))
patch /= np.linalg.norm(patch, axis=2)[..., None]
ax.plot_surface(*patch.transpose(2, 0, 1), color='#dceee9', shade=False,
rcount=61, ccount=61, linewidth=0, antialiased=False, zorder=2)
colors = ['#cf6422', '#126f80', '#126f80']
for (j, k), color in zip([(1, 2), (2, 0), (0, 1)], colors):
curve = arc(vertices[j], vertices[k])
assert np.allclose(np.linalg.norm(curve, axis=1), 1)
ax.plot(*curve.T, color=color, lw=3.2, zorder=4)
for vertex, label, align in zip(vertices, ['A', 'B', 'C'], ['center', 'right', 'left']):
ax.scatter(*vertex, s=28, color='#263945', depthshade=False, zorder=6)
ax.text(*(1.12 * vertex), label, ha=align, va='bottom', fontsize=16, zorder=7)
for (j, k), label, color, factor in zip(
[(1, 2), (2, 0), (0, 1)],
[r'$a=75.5225^\circ$', r'$b=60^\circ$', r'$c=60^\circ$'],
colors, [1.05, 1.13, 1.13]):
point = arc(vertices[j], vertices[k], 3)[1] * factor
ax.text(*point, label, ha='center', va='center', color=color, fontsize=12,
bbox={'facecolor': 'white', 'edgecolor': 'none', 'alpha': .9}, zorder=8)
# An angle arc at A lies on the sphere, between the two outgoing directions.
t = np.linspace(0, np.pi / 2, 50)
eps = .17
angle_arc = np.array([np.sin(eps) * np.cos(t), np.sin(eps) * np.sin(t),
np.full_like(t, np.cos(eps))])
ax.plot(*angle_arc, color='#703e98', lw=2.5, zorder=5)
ax.text(.18, .18, .87, r'$90^\circ$', color='#703e98', ha='center', fontsize=12,
bbox={'facecolor': 'white', 'edgecolor': 'none', 'alpha': .9}, zorder=9)
fig.suptitle('A right triangle on the unit sphere', fontsize=18, y=.96)
fig.text(.65, .78, 'Angular sides', weight='bold', fontsize=14)
fig.text(.65, .71, r'$b=c=\pi/3=60^\circ$', fontsize=15)
fig.text(.65, .63, r'$\cos a=\cos b\cos c=1/4$', fontsize=14)
fig.text(.65, .55, r'$a\approx1.318116\ \mathrm{rad}$', fontsize=15)
fig.text(.65, .43, 'Interior angles', weight='bold', fontsize=14)
fig.text(.65, .36, r'$A=90^\circ$', color='#703e98', fontsize=15)
fig.text(.65, .29, r'$B=C\approx63.434949^\circ$', fontsize=14)
fig.text(.65, .17, 'On radius R: physical lengths\nare R times the sides in radians.',
fontsize=11, linespacing=1.6)
fig.text(.5, .035, 'All three edges are shorter great-circle arcs; angles are measured between tangents.',
ha='center', fontsize=11)
fig.savefig(Path.cwd() / Path(__file__).with_suffix('.png').name,
dpi=130, facecolor='white', transparent=False)
plt.close(fig)
if __name__ == '__main__':
main()