codexstatic-friction-incline-onset-geometry.py
static-friction-incline-onset-geometry.py
"""Explain incline force balance and weight decomposition in separate panels.
Tested with Python 3.14, matplotlib 3.10.7 and numpy 2.3.5.
Run from _media; output is static-friction-incline-onset-geometry.png in CWD.
"""
from pathlib import Path
import matplotlib.pyplot as plt
from matplotlib.patches import Arc, Polygon
import numpy as np
MU_STATIC = 0.4
THETA = np.arctan(MU_STATIC)
UPHILL = np.array([np.cos(THETA), np.sin(THETA)])
OUTWARD = np.array([-np.sin(THETA), np.cos(THETA)])
def arrow(ax, origin, vector, color, label, text_offset, linestyle="-", label_end=True):
end = origin + vector
ax.annotate("", end, origin,
arrowprops={"arrowstyle": "-|>", "lw": 2.3, "color": color,
"linestyle": linestyle, "mutation_scale": 16})
anchor = end if label_end else origin + 0.5 * vector
ax.text(*(anchor + np.array(text_offset)), label, fontsize=12, color=color,
ha="center", va="center")
def main():
plt.rcParams.update({"font.size": 12, "font.family": "DejaVu Sans"})
fig, axes = plt.subplots(1, 2, figsize=(10, 5.4), dpi=120, facecolor="white")
blue, green, orange = "#176797", "#278148", "#b25623"
ax = axes[0]
ax.set_title("Actual forces on the resting block", fontsize=13, pad=15)
ax.plot([0, 5.2], [0, 5.2 * np.tan(THETA)], color="#687783", lw=2)
ax.plot([0, 2], [0, 0], color="#99a3aa", lw=1)
ax.add_patch(Arc((0, 0), 1.6, 1.6, theta1=0,
theta2=np.degrees(THETA), color="#394954"))
ax.text(1.00, 0.18, r"$\theta$", fontsize=14)
base = 2.55 * UPHILL
w, h = 0.95, 0.7
corners = np.array([base-w/2*UPHILL, base+w/2*UPHILL,
base+w/2*UPHILL+h*OUTWARD, base-w/2*UPHILL+h*OUTWARD])
ax.add_patch(Polygon(corners, facecolor="#e3e9ed", edgecolor="#506471", lw=1.3))
center = base + h/2 * OUTWARD
scale = 1.85
arrow(ax, center, scale*np.cos(THETA)*OUTWARD, blue, r"$N$", (-0.2, 0.22))
arrow(ax, center, scale*np.sin(THETA)*UPHILL, green, r"$f_s$", (0.1, 0.32))
arrow(ax, center, np.array([0., -scale]), orange, r"$mg$", (0.25, -0.15))
arrow(ax, np.array([3.9, 0.7]), -0.8*UPHILL, "#5c6470", "", (0, 0))
ax.text(3.85, 0.14, "Impending slip: downhill", fontsize=10, ha="center")
ax.text(2.15, -1.00, r"$f_s=mg\sin\theta$, $N=mg\cos\theta$", ha="center", fontsize=12)
ax.set(xlim=(-0.25, 5.55), ylim=(-1.45, 3.9))
ax = axes[1]
ax.set_title("Decompose weight separately", fontsize=13, pad=15)
origin = np.array([0., 0.])
size = 2.8
downhill = -size*np.sin(THETA)*UPHILL
inward = -size*np.cos(THETA)*OUTWARD
weight = np.array([0., -size])
arrow(ax, origin, downhill, green, r"$mg\sin\theta$", (-0.3, -0.25))
arrow(ax, downhill, inward, blue, r"$mg\cos\theta$", (-1.0, -0.08), label_end=False)
arrow(ax, origin, weight, orange, r"$mg$", (-0.3, -0.15), linestyle="--")
ax.plot([0, inward[0]], [0, inward[1]], color="#597b96", lw=1, ls=":")
corner = downhill
tick_size = 0.15
tick = np.array([corner-tick_size*UPHILL,
corner-tick_size*UPHILL-tick_size*OUTWARD,
corner-tick_size*OUTWARD])
ax.plot(tick[:, 0], tick[:, 1], color="#65727e", lw=1)
ax.add_patch(Arc((0, 0), 1.1, 1.1, theta1=-90,
theta2=-90+np.degrees(THETA), color="#394954"))
ax.text(0.14, -0.8, r"$\theta$", fontsize=14)
ax.text(1.22, -1.0, "Inward normal", fontsize=10, ha="center", color="#597b96")
ax.annotate("", (0.85*inward[0], 0.85*inward[1]), (1.2, -1.18),
arrowprops={"arrowstyle": "-", "lw": 0.8, "color": "#597b96"})
ax.text(0, -3.50, r"At onset: $\tan\theta_c=\mu_s=0.40$", fontsize=12, ha="center")
ax.text(0, -3.90, r"$\theta_c\approx21.80^\circ$; a model angle, not a measurement", fontsize=10, ha="center")
ax.set(xlim=(-2.15, 2.9), ylim=(-4.25, 1.1))
for ax in axes:
ax.set_aspect("equal")
ax.axis("off")
ax.set_facecolor("white")
fig.suptitle("Incline geometry: demand reaches the static-friction bound", fontsize=15, y=0.98)
fig.subplots_adjust(left=0.02, right=0.98, bottom=0.03, top=0.88, wspace=0.12)
fig.savefig(Path(__file__).with_suffix(".png").name, facecolor="white", transparent=False)
plt.close(fig)
if __name__ == "__main__":
main()