codex/spherical-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()