codex/vector-graphics-path-transform.py

vector-graphics-path-transform.py
"""Original quadratic path and affine image; no SVG assets are written.

Run from _media with MPLBACKEND=Agg. Tested with Python 3.14,
NumPy 2.3 and Matplotlib 3.10; output goes to the current directory.
"""

from pathlib import Path

import matplotlib.pyplot as plt
from matplotlib.patches import PathPatch
from matplotlib.path import Path as PlotPath
import numpy as np


CONTROL = np.array([[1.0, 4.0], [3.0, 0.0], [5.0, 4.0]])
LINEAR = np.array([[1.5, 0.5], [0.0, 1.0]])
OFFSET = np.array([1.0, 1.0])


def bezier(t, points):
    t = np.asarray(t)[..., None]
    return (1 - t) ** 2 * points[0] + 2 * t * (1 - t) * points[1] + t**2 * points[2]


def transform(points):
    return np.asarray(points) @ LINEAR.T + OFFSET


def main():
    mapped = transform(CONTROL)
    np.testing.assert_allclose(mapped, [[4.5, 5], [5.5, 1], [10.5, 5]])
    np.testing.assert_allclose(bezier(0.5, CONTROL), [3, 2])
    np.testing.assert_allclose(bezier(0.5, mapped), [6.5, 3])
    samples = np.linspace(0, 1, 501)
    np.testing.assert_allclose(bezier(samples, mapped), transform(bezier(samples, CONTROL)))

    plt.rcParams.update({"font.size": 11, "figure.facecolor": "white", "axes.facecolor": "white"})
    fig, axes = plt.subplots(1, 2, figsize=(10.8, 5.6), dpi=110)
    fig.subplots_adjust(left=0.07, right=0.98, bottom=0.21, top=0.79, wspace=0.24)
    fig.suptitle("Stored path geometry and an affine transformation", y=0.98, fontsize=16)
    fig.text(0.5, 0.91, r"Analytical illustration • $T(x,y)=(1.5x+0.5y+1,\ y+1)$", ha="center", fontsize=12)

    configurations = [
        (CONTROL, "Original: M 1 4 Q 3 0 5 4 Z", (0, 6), (-0.8, 5.1), r"Filled area $16/3$"),
        (mapped, "Image: M 4.5 5 Q 5.5 1 10.5 5 Z", (3.5, 11.5), (-0.1, 6.1), r"Filled area $8$; area factor $|\det A|=1.5$"),
    ]
    for ax, (points, title, xlim, ylim, note) in zip(axes, configurations):
        path = PlotPath(np.vstack([points, points[0]]),
                        [PlotPath.MOVETO, PlotPath.CURVE3, PlotPath.CURVE3, PlotPath.CLOSEPOLY])
        ax.add_patch(PathPatch(path, facecolor="#cfe8f5", edgecolor="#136a9b", lw=2.3, zorder=2))
        ax.plot(points[:, 0], points[:, 1], "--", color="#7a7a7a", lw=1.4, zorder=3)
        ax.scatter(points[:, 0], points[:, 1], s=36, color="#333333", zorder=4)
        middle = bezier(0.5, points)
        ax.scatter(*middle, marker="D", s=46, color="#c35718", zorder=5)
        for index, (x, y) in enumerate(points):
            shift = (0, 13) if index == 1 else (0, -22)
            ax.annotate(f"P{index} ({x:g}, {y:g})", (x, y), xytext=shift,
                        textcoords="offset points", ha="center", fontsize=10)
        ax.annotate(f"t = 1/2\n({middle[0]:g}, {middle[1]:g})", middle,
                    xytext=(12, 12), textcoords="offset points", color="#994411", fontsize=10)
        ax.set(xlim=xlim, ylim=ylim, xlabel="x", ylabel="y (increases downward)")
        ax.invert_yaxis()
        ax.set_aspect("equal", adjustable="box")
        ax.set_title(title, fontsize=11, pad=14)
        ax.grid(color="#eeeeee", lw=0.7)
        ax.text(0.5, -0.24, note, transform=ax.transAxes, ha="center", fontsize=11)
    fig.text(0.5, 0.035, "Blue: filled path and closing segment   •   Dashed: control polygon   •   Orange: curve midpoint",
             ha="center", fontsize=10)
    fig.savefig(Path(__file__).with_suffix('.png').name, facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()