Draw each full medial tire cut from the dual-cut experiment

Add draw_tire_cuts_png (and a --tire-png flag): one panel per recognised
tread showing the annular cycle, up/down/bite teeth, walk-depth labels, and
cut slits, ported from medial_tire_cut_labelling.to_tikz. Render the
function-D (seed 7) graph's tire cuts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 10:37:42 -04:00
parent 367b5adc71
commit 0a3d7b2615
2 changed files with 129 additions and 0 deletions
@@ -304,6 +304,130 @@ def draw_png(result, path, scale=6.0):
plt.close(fig) plt.close(fig)
def _tire_coords(g, r_ann=1.0, r_up=1.46, r_down=0.60):
"""Annular/teeth coordinates for one tread, matching
``medial_tire_cut_labelling.to_tikz``: a_0 at the top, k increasing CW."""
import math
n = g.n
def ang(k):
return math.radians(90.0 - k * 360.0 / n)
def mid(i):
a0, a1 = ang(i), ang((i + 1) % n)
return math.atan2(math.sin(a0) + math.sin(a1), math.cos(a0) + math.cos(a1))
pos = {f"a{k}": (r_ann * math.cos(ang(k)), r_ann * math.sin(ang(k)))
for k in range(n)}
for i in g.up_edges:
a = mid(i)
pos[f"u{i}"] = (r_up * math.cos(a), r_up * math.sin(a))
for i in g.singleton_down_edges:
a = mid(i)
pos[f"d{i}"] = (r_down * math.cos(a), r_down * math.sin(a))
for (i, j) in g.bites:
pts = [pos[f"a{i}"], pos[f"a{(i + 1) % n}"],
pos[f"a{j}"], pos[f"a{(j + 1) % n}"]]
cx = sum(p[0] for p in pts) / 4.0
cy = sum(p[1] for p in pts) / 4.0
pos[f"p{i}_{j}"] = (0.9 * cx, 0.9 * cy)
return pos
def _draw_tread(ax, g, depth, cuts, entry_edge, title):
"""Draw one full medial tire cut on ``ax`` (annular cycle, teeth, walk-depth
labels, cut slits), mirroring ``medial_tire_cut_labelling.to_tikz``."""
import math
n = g.n
pos = _tire_coords(g)
def seg(a, b, **kw):
ax.plot([pos[a][0], pos[b][0]], [pos[a][1], pos[b][1]], **kw)
# annular cycle
xs = [pos[f"a{k}"][0] for k in range(n)] + [pos["a0"][0]]
ys = [pos[f"a{k}"][1] for k in range(n)] + [pos["a0"][1]]
ax.plot(xs, ys, color="black", lw=1.4, zorder=1)
# spokes (teeth)
for i in g.up_edges:
seg(f"u{i}", f"a{i}", color="0.55", lw=0.6, zorder=1)
seg(f"u{i}", f"a{(i + 1) % n}", color="0.55", lw=0.6, zorder=1)
for i in g.singleton_down_edges:
seg(f"d{i}", f"a{i}", color="0.55", lw=0.6, zorder=1)
seg(f"d{i}", f"a{(i + 1) % n}", color="0.55", lw=0.6, zorder=1)
for (i, j) in g.bites:
apex = f"p{i}_{j}"
for e in (i, j):
seg(apex, f"a{e}", color="0.55", lw=0.6, zorder=1)
seg(apex, f"a{(e + 1) % n}", color="0.55", lw=0.6, zorder=1)
# vertices
for k in range(n):
ax.plot(*pos[f"a{k}"], "o", ms=3, color="black", zorder=3)
for i in g.up_edges:
ax.plot(*pos[f"u{i}"], "o", ms=5, mfc="#cfe0f3", mec="#3a6ea5", zorder=3)
for i in g.singleton_down_edges:
ax.plot(*pos[f"d{i}"], "o", ms=5, mfc="#f3cfcf", mec="#a53a3a", zorder=3)
for (i, j) in g.bites:
ax.plot(*pos[f"p{i}_{j}"], "o", ms=6, mfc="#e8a0a0", mec="#a53a3a", zorder=3)
# walk-depth labels along each spoke
if depth is not None:
for edge in range(n):
ax_, ay = pos[g.apex_of_edge(edge)]
a, b = pos[f"a{edge}"], pos[f"a{(edge + 1) % n}"]
mx, my = 0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1])
lx, ly = ax_ + 0.5 * (mx - ax_), ay + 0.5 * (my - ay)
ax.text(lx, ly, str(depth[edge]), fontsize=7, fontweight="bold",
ha="center", va="center", zorder=4)
# cut slits
for c in cuts or []:
if c.vertex is None:
continue
vx, vy = pos[f"a{c.vertex}"]
rad = math.atan2(vy, vx)
dx, dy = 0.16 * math.cos(rad), 0.16 * math.sin(rad)
ax.plot([vx - dx, vx + dx], [vy - dy, vy + dy],
color="#cc2020", lw=2.0, zorder=5)
ax.text(vx + 0.34 * math.cos(rad), vy + 0.34 * math.sin(rad),
f"cut {c.order + 1}", fontsize=6, color="#cc2020",
ha="center", va="center", zorder=5)
# entry marker
if entry_edge is not None:
ex, ey = pos[g.apex_of_edge(entry_edge)]
rad = math.atan2(ey, ex)
ax.text(ex + 0.34 * math.cos(rad), ey + 0.34 * math.sin(rad),
"entry", fontsize=6, color="#3a6ea5", ha="center", va="center")
ax.set_title(title, fontsize=8)
ax.set_aspect("equal")
ax.axis("off")
def draw_tire_cuts_png(result, path):
"""Render every recognised tread's full medial tire cut, one panel each."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
res = result["results"]
depths = sorted(res)
if not depths:
raise ValueError("no recognised treads to draw")
fig, axes = plt.subplots(1, len(depths), figsize=(5.2 * len(depths), 5.4))
if len(depths) == 1:
axes = [axes]
for ax, d in zip(axes, depths):
rec = res[d]
g = rec["g"]
title = (f"tread {d}: |A(T)|={g.n} word={g.tooth_word}\n"
f"bites={sorted(g.bites)} entry=e{rec['entry_edge']} "
f"start_depth={rec['start_depth']} cuts={len(rec['cuts'])}")
_draw_tread(ax, g, rec["depth"], rec["cuts"], rec["entry_edge"], title)
fig.suptitle(f"full medial tire cuts -- source {result['source']}, "
f"root entry e{result['entry_edge']}", fontsize=10)
fig.tight_layout()
fig.savefig(path, dpi=150)
plt.close(fig)
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
@@ -315,6 +439,8 @@ def main():
parser.add_argument("--entry", type=int, default=None, parser.add_argument("--entry", type=int, default=None,
help="fix the root entry tooth (requires --source)") help="fix the root entry tooth (requires --source)")
parser.add_argument("--png", metavar="PATH", help="render the dual cut to PNG") parser.add_argument("--png", metavar="PATH", help="render the dual cut to PNG")
parser.add_argument("--tire-png", metavar="PATH",
help="render each full medial tire cut to PNG")
args = parser.parse_args() args = parser.parse_args()
rng = random.Random(args.seed) rng = random.Random(args.seed)
@@ -338,6 +464,9 @@ def main():
if args.png: if args.png:
draw_png(result, args.png) draw_png(result, args.png)
print(f"wrote {args.png}") print(f"wrote {args.png}")
if args.tire_png:
draw_tire_cuts_png(result, args.tire_png)
print(f"wrote {args.tire_png}")
if __name__ == "__main__": if __name__ == "__main__":
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB