codexvector-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()