# --- Nur Zeichen- und Rechenhilfen. Die EINE Lastquelle ist momente.py
# (der Kräftefahrplan der fünf Momente), der EINE Physikkern ist reglerkern.py
# über spannungsfelder.py; diese Zelle konfiguriert nur und deutet nichts.
import json
import math
import pathlib
import sys
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.tri as mtri
from matplotlib.patches import Polygon as MplPolygon
sys.path.insert(0, str(pathlib.Path("programme/gemeinsam").resolve()))
sys.path.insert(0, str(pathlib.Path("programme/kap14").resolve()))
sys.path.insert(0, str(pathlib.Path("programme/kap15").resolve()))
import momente as M
import spannungsfelder as SF
KONTUR = np.array(json.loads(
pathlib.Path("../geometrie/kalkaneus_kontur.json").read_text())["punkte"]) * 1000.0
def komma(x, stellen=2):
s = f"{x:.{stellen}f}"
if "." in s:
s = s.rstrip("0").rstrip(".")
return s.replace("-", "−").replace(".", ",")
_NETZ_CACHE = {}
def lade(stufe):
# Jede Netzstufe wird genau einmal gelöst; alle Zellen teilen sich dieselbe
# Studie (sie hält ihre Verschiebungen selbst zwischen).
if stufe not in _NETZ_CACHE:
_NETZ_CACHE[stufe] = SF.neue_studie(json.loads(
pathlib.Path(f"netze/netz_{stufe}.json").read_text()))
return _NETZ_CACHE[stufe]
def grad(x, stellen=1):
return komma(x, stellen) + "°"
def zwei(x):
# Feste zwei Nachkommastellen — für Zahlen, die in einer Tafel untereinander
# stehen und dort gleich lang sein sollen (komma() schneidet Endnullen ab).
return f"{x:.2f}".replace(".", ",")
def orientierung(ax, x_mass=28):
# Orientierung am unteren Rand (wie im Canvas), damit der Panel-Titel oben
# frei bleibt. Der 10-mm-Maßstab liegt UNTER der Orientierungszeile (eigene
# Höhe), damit beide auch in stark komprimierten Panels nicht überlappen.
# Der obere Rand endet dicht über der Kontur (ymax = 42,06 mm): Titel und
# Herkunftszeilen stehen ÜBER der Achse (set_title / transAxes), nicht in
# den Daten — sonst schneidet die Kontur den Text (Bildprüfung 01.09.2026).
ax.text(-11, -6.0, "hinten (Ferse)", fontsize=6.5, color="#666")
ax.text(71, -6.0, "vorn (Zehen)", fontsize=6.5, color="#666", ha="right")
ax.annotate("oben", xy=(-7, 26), xytext=(-7, 12), fontsize=6.5, color="#444",
ha="center", arrowprops=dict(arrowstyle="->", color="#444", lw=1.2))
ax.plot([x_mass, x_mass + 10], [-9.4, -9.4], color="#333", lw=2.0,
solid_capstyle="butt")
ax.text(x_mass + 5, -11.1, "10 mm", ha="center", fontsize=6.5, color="#333")
ax.set_xlim(-13, 73)
ax.set_ylim(-12.4, 44.5)
ax.set_aspect("equal")
ax.axis("off")
def feld_bild(ax, studie, feld_mpa, titel, vmax=10.0, cmap="turbo",
skalen_titel="Von-Mises", colorbar=True):
"""Ein Elementfeld (MPa) über dem Buchnetz, feste turbo-Skala 0..vmax.
Gibt das Mappable zurück; mit colorbar=False wird KEINE panel-eigene Colorbar
gezeichnet (für eine gemeinsame Colorbar einer ganzen Bildreihe)."""
kn = studie.knoten * 1000.0
tri = mtri.Triangulation(kn[:, 0], kn[:, 1], studie.dreiecke)
tp = ax.tripcolor(tri, facecolors=np.asarray(feld_mpa), cmap=cmap,
vmin=0.0, vmax=vmax)
if colorbar:
cb = plt.colorbar(tp, ax=ax, fraction=0.046, pad=0.03, extend="max")
cb.set_label(f"{skalen_titel} (MPa, ≥ {komma(vmax, 0)} gesättigt)", fontsize=7)
cb.ax.tick_params(labelsize=7)
ax.set_title(titel, fontsize=9, weight="bold", pad=4)
orientierung(ax)
return tp
def richtungsstriche_bild(ax, studie, traj, titel, unter=3, legende=True):
"""Lokale Hauptspannungsrichtungen als kurze, auf die Kontur GECLIPPTE Striche
(entartete/isotrope Elemente ausgelassen). KEINE integrierten Trajektorien."""
ax.fill(KONTUR[:, 0], KONTUR[:, 1], color="#f2ece0", ec="#8a6a4a", lw=1.0,
zorder=0)
x = np.asarray(traj["x_mm"])[::unter]
y = np.asarray(traj["y_mm"])[::unter]
w = np.asarray(traj["winkel"])[::unter]
s1 = np.asarray(traj["sig1_mpa"])[::unter]
s2 = np.asarray(traj["sig2_mpa"])[::unter]
L = 1.6
for k in range(len(x)):
if not np.isfinite(w[k]):
continue # isotroper Punkt: Richtung unbestimmt
if s1[k] > 0: # Zug entlang σ1
ax_, ay_, bx_, by_ = SF.clip_strich(
x[k], y[k], math.cos(w[k]), math.sin(w[k]), L, KONTUR)
ax.plot([ax_, bx_], [ay_, by_], color="#c0392b", lw=1.0, zorder=2)
if s2[k] < 0: # Druck entlang σ2 (senkrecht)
w2 = w[k] + math.pi / 2.0
ax_, ay_, bx_, by_ = SF.clip_strich(
x[k], y[k], math.cos(w2), math.sin(w2), L, KONTUR)
ax.plot([ax_, bx_], [ay_, by_], color="#1f4e9c", lw=1.0, zorder=2)
if legende:
ax.plot([], [], color="#c0392b", lw=1.4, label="Zug (σ1 > 0)")
ax.plot([], [], color="#1f4e9c", lw=1.4, label="Druck (σ2 < 0)")
ax.legend(loc="lower right", bbox_to_anchor=(1.0, 0.16), fontsize=6.5, framealpha=0.9)
ax.set_title(titel, fontsize=9, weight="bold", pad=4)
orientierung(ax)
def trabekel_lehrschema(ax, titel):
"""Literaturbasiertes Lehrschema der Kalkaneus-Trabekelgruppen — quellen-
unabhängig neu gezeichnet nach der anatomischen Taxonomie (Gefen & Seliktar
2004: dorsal–plantar, anterior–posterior und zwei schräge Gruppen; RadioGraphics
2022: zwei Druck-, drei Zuggruppen samt Neutraldreieck). KEINE Fotografie,
keine Messung, keine gerechnete Struktur, nicht an das Rechenergebnis angepasst."""
ax.fill(KONTUR[:, 0], KONTUR[:, 1], color="#f2ece0", ec="#8a6a4a", lw=1.0)
t = np.linspace(0.0, 1.0, 60)
def bogen(p0, p1, p2, farbe, lw=1.6):
pts = ((1 - t)[:, None] ** 2 * np.array(p0)
+ 2 * (1 - t)[:, None] * t[:, None] * np.array(p1)
+ (t[:, None] ** 2) * np.array(p2))
ax.plot(pts[:, 0], pts[:, 1], color=farbe, lw=lw, alpha=0.9)
# ALLE Stützpunkte liegen innerhalb der 48-Punkt-Buchkontur (nachgemessen:
# jede Kurve zu 100 % innen, kleinster Randabstand 1,17 mm). Die frühere
# Fassung zeichnete die anteriore Druckgruppe, zwei Zuggruppen und das
# Neutraldreieck weitgehend NEBEN den Knochen (Bildprüfung 01.09.2026:
# 12 %, 38 %, 53 % bzw. 1 % innen).
# Druckgruppen (blau): (1) dorsal–plantar unter der Talus-Facette zum Tuber,
# (2) anteriore Druckgruppe im vorderen Gelenkfortsatz.
bogen((20, 33), (11, 16), (7, 5), "#1f4e9c")
bogen((26, 34), (18, 15), (12, 4.5), "#1f4e9c")
bogen((62, 30), (61, 24), (59, 19), "#1f4e9c")
# Zuggruppen (rot): (1) plantar–längs entlang der Unterkante, (2) posterior-
# aufsteigend zum Sinus tarsi, (3) anterior-plantar schräg aufsteigend.
bogen((3, 7), (16, 4.5), (33, 15.5), "#c0392b")
bogen((6, 8), (14, 20), (12, 28), "#c0392b")
bogen((8, 10), (28, 15), (48, 22), "#c0392b")
# Neutraldreieck (anterior-inferior, sparsame Bälkchen): grau schraffiert,
# vollständig innerhalb der Kontur. Die Beschriftung steht auf weißem Grund,
# damit die querende Zuggruppe sie nicht unlesbar macht.
tri = np.array([[32, 16.0], [52, 21.0], [40, 28.0]])
ax.add_patch(MplPolygon(tri, closed=True, facecolor="#dddddd", edgecolor="#999",
hatch="xx", alpha=0.6, lw=0.8))
ax.text(43.1, 22.6, "Neutral-\ndreieck", fontsize=6.5, color="#555", ha="center",
va="center", bbox=dict(facecolor="white", alpha=0.82, edgecolor="none",
pad=1.0))
ax.plot([], [], color="#1f4e9c", lw=1.6, label="Druckgruppen (2)")
ax.plot([], [], color="#c0392b", lw=1.6, label="Zuggruppen (3)")
ax.legend(loc="lower right", bbox_to_anchor=(1.0, 0.16), fontsize=6.5, framealpha=0.9)
ax.set_title(titel, fontsize=9, weight="bold", pad=30)
ax.text(0.0, 1.072, "literaturbasiertes Lehrschema nach der anatomischen Taxonomie",
transform=ax.transAxes, fontsize=6.5, color="#a33", style="italic",
va="bottom")
ax.text(0.0, 1.020, "(Gefen & Seliktar 2004; Übersichtsarbeit in "
"RadioGraphics 2022) — keine Fotografie, keine Rechnung",
transform=ax.transAxes, fontsize=6.5, color="#a33", style="italic",
va="bottom")
orientierung(ax)
# Zahlen, die die Prosa dieses Kapitels nennt — aus derselben Rechnung wie die
# Regler, damit im Text keine abgeschriebene Zahl steht.
AUFTRITT = lade("mittel").moment(0) # Fersenauftritt
ABSTOSS = lade("mittel").moment(4) # Abstoß
STUFEN = ("grob", "mittel", "fein")
MITTELFELD = {s: lade(s).mittelfeld() for s in STUFEN}
ZONE = {s: MITTELFELD[s]["zone_vm_mpa"] for s in STUFEN}
SPITZE = {s: MITTELFELD[s]["spitze_mpa"] for s in STUFEN}
ZONE_SCHWANKUNG = 100 * (max(ZONE.values()) - min(ZONE.values())) / max(ZONE.values())
SPITZE_FAKTOR = SPITZE["fein"] / SPITZE["grob"]