Fix walkthrough figure to use verified planar embeddings

The walkthrough previously used a concentric layout whose outer-triangle->ring
spokes can cross -- not a valid plane embedding. Rebuild draw_walkthrough.py on
networkx planar_layout with an explicit crossing check: G, G', and the medial
M(G') drawn at edge midpoints are each verified crossing-free before rendering.
G' is embedded once and reused for panels B/C/D; G reuses it when still planar.

The medial-at-midpoints drawing is planar except for the medial triangle of the
geometric outer face (its midpoint-chords would cut across the unbounded region),
so those three edges are detected via the convex hull and omitted; the remainder
is verified crossing-free. Note updated to describe the embedding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 00:16:03 -04:00
parent e7e8536559
commit 20e2cc94b4
3 changed files with 171 additions and 80 deletions
@@ -16,11 +16,16 @@ colour order = (1,0,2). Panels:
{(0,3),(3,7),(3,8),(3,9)}: quad medials become 2,1,1,2 -> reducible;
remove w, restored diagonal (3,4) takes the third colour 0.
Layout is concentric by BFS level (tire-tread model), the level-1 ring placed in
seam-cycle order, so spokes read outward. Run with the repo venv python.
Embedding: networkx planar_layout (canonical-ordering straight-line embedding),
recentred, with EVERY panel verified crossing-free before drawing -- G, G', and
the medial M(G') drawn at edge midpoints. G' is embedded once and reused for
panels B/C/D; G reuses it (minus w, with the diagonal 3-4 restored) when that is
still crossing-free, else it is embedded independently. Run with the repo venv
python (numpy + matplotlib + networkx).
"""
import os, random
import numpy as np
import networkx as nx
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
@@ -29,82 +34,48 @@ import kempe_even_program_harness as H
HERE = os.path.dirname(os.path.abspath(__file__))
PAL = {0: "#e6550d", 1: "#3182bd", 2: "#31a354"} # colours "1","2","3"(=2)
RAD = {0: 2.7, 1: 1.45, 2: 0.0}
def concentric(g, outer, an, ring_order):
pos = {}
for i, v in enumerate(outer):
a = 90 - i * 120
pos[v] = (np.cos(np.radians(a)) * RAD[0], np.sin(np.radians(a)) * RAD[0])
m = len(ring_order)
for i, v in enumerate(ring_order):
a = 90 + i * 360.0 / m
pos[v] = (np.cos(np.radians(a)) * RAD[1], np.sin(np.radians(a)) * RAD[1])
for v in sorted(g.rot):
if v in pos: continue
pos[v] = (0.0, 0.0) # hub (level 2)
return pos
def planar_pos(g):
nxg = nx.Graph()
nxg.add_nodes_from(g.rot)
for ed in g.edges():
a, b = tuple(ed); nxg.add_edge(a, b)
ok, _ = nx.check_planarity(nxg)
assert ok, "graph not planar?!"
pos = nx.planar_layout(nxg)
pts = np.array([pos[v] for v in g.rot]); c = pts.mean(axis=0)
s = np.abs(pts - c).max()
return {v: ((pos[v][0]-c[0])/s, (pos[v][1]-c[1])/s) for v in g.rot}
def seg_cross(p, q, r, s):
def o(a, b, c):
return (b[0]-a[0])*(c[1]-a[1]) - (b[1]-a[1])*(c[0]-a[0])
d1, d2, d3, d4 = o(r, s, p), o(r, s, q), o(p, q, r), o(p, q, s)
return ((d1 > 0) != (d2 > 0)) and ((d3 > 0) != (d4 > 0))
def crossings(edges, pos):
bad = []
for i in range(len(edges)):
a, b = edges[i]
for j in range(i+1, len(edges)):
c, d = edges[j]
if len({a, b, c, d}) < 4:
continue
if seg_cross(pos[a], pos[b], pos[c], pos[d]):
bad.append((edges[i], edges[j]))
return bad
def mid(p, q): return ((p[0]+q[0])/2, (p[1]+q[1])/2)
def draw_graph(ax, g, pos, level=None, bold_cycle=None, shade_quad=None, wvert=None):
if shade_quad:
ax.add_patch(Polygon([pos[v] for v in shade_quad], closed=True,
color="#ffe2bf", zorder=0))
bold = set()
if bold_cycle:
for i in range(len(bold_cycle)):
bold.add(frozenset((bold_cycle[i], bold_cycle[(i+1) % len(bold_cycle)])))
for ed in g.edges():
a, b = tuple(ed); pa, pb = pos[a], pos[b]
if ed in bold:
ax.plot([pa[0], pb[0]], [pa[1], pb[1]], color="#d62728", lw=2.8, zorder=2)
else:
ax.plot([pa[0], pb[0]], [pa[1], pb[1]], color="#888888", lw=1.0, zorder=1)
for v, p in pos.items():
c = "#d62728" if (wvert is not None and v == wvert) else "#222222"
ax.plot(*p, "o", color="white", ms=17, zorder=4)
ax.plot(*p, "o", ms=17, mfc="white", mec=c, mew=1.7, zorder=5)
ax.annotate(str(v), p, ha="center", va="center", fontsize=9,
fontweight="bold", color=c, zorder=6)
if level is not None:
ax.annotate(f"L{level[v]}", p, textcoords="offset points",
xytext=(12, 10), fontsize=6.5, color="#999999", zorder=6)
def draw_medial(ax, g, pos, col, halo=None, restored=None):
for ed in g.edges():
a, b = tuple(ed); pa, pb = pos[a], pos[b]
ax.plot([pa[0], pb[0]], [pa[1], pb[1]], color="#e3e3e3", lw=0.8, zorder=0)
adj = H.medial_adj(g)
mpos = {mm: mid(pos[tuple(mm)[0]], pos[tuple(mm)[1]]) for mm in adj}
seen = set()
for mm in adj:
for b in adj[mm]:
k = frozenset((mm, b))
if k in seen: continue
seen.add(k)
pa, pb = mpos[mm], mpos[b]
ax.plot([pa[0], pb[0]], [pa[1], pb[1]], color="#c9c9c9", lw=0.8, zorder=1)
if restored is not None:
a, b = restored
ax.plot([pos[a][0], pos[b][0]], [pos[a][1], pos[b][1]], color="#d62728",
lw=1.8, ls=":", zorder=2)
rp = mid(pos[a], pos[b])
ax.plot(*rp, "s", color=PAL[col[H.e(a, b)]], ms=13, mec="#d62728",
mew=2.0, zorder=7)
halo = halo or set()
for mm, p in mpos.items():
if mm not in col: continue
if mm in halo:
ax.plot(*p, "o", color="#000000", ms=16, zorder=5)
ax.plot(*p, "o", color=PAL[col[mm]], ms=10.5, mec="black", mew=0.8, zorder=6)
# ---- build G and G' ------------------------------------------------------
rng = random.Random(0)
g, outer = H.ring_triangulation([3, 5], 'hub', rng)
an = H.Analysis(g.copy(), outer)
ring = [k for k, c in an.seams if k == 1][0:1] and \
[c for k, c in an.seams if k == 1][0]
posG = concentric(g, outer, an, ring)
ring = [c for k, c in an.seams if k == 1][0]
prep = H._prep_gadgets(g.copy(), outer)
template, an_g, gadgets = prep
@@ -112,39 +83,155 @@ gg = template.copy()
w, u, v, x, t = gg.insert_diamond(3, 4)
an2 = H.Analysis(gg, outer)
ring2 = [c for k, c in an2.seams if k == 1][0]
posGp = concentric(gg, outer, an2, ring2)
posGp[w] = mid(posGp[3], posGp[4])
quad = H.quad_of(gg, w, u, v) # (3,0,4,8)
quad = H.quad_of(gg, w, u, v) # (3,0,4,8)
# embed G' (verified), reuse for G if still crossing-free else embed G alone
posGp = planar_pos(gg)
edgesGp = [tuple(e) for e in gg.edges()]
badGp = crossings(edgesGp, posGp)
print("G' crossings:", badGp if badGp else "NONE")
assert not badGp
posG = {vv: posGp[vv] for vv in g.rot}
edgesG = [tuple(e) for e in g.edges()]
badG = crossings(edgesG, posG)
if badG:
print("G reuse crossed; embedding G independently")
posG = planar_pos(g)
badG = crossings([tuple(e) for e in g.edges()], posG)
print("G crossings:", badG if badG else "NONE")
assert not badG
# medial drawn at edge midpoints. The medial drawing at midpoints is planar
# EXCEPT for the medial triangle of whichever face is geometrically OUTER
# (its three midpoint-chords would cut straight across the unbounded region), so
# we omit exactly those three edges, then verify the rest are crossing-free.
def convex_hull(points):
pts = sorted(points)
def cross(o, a, b):
return (a[0]-o[0])*(b[1]-o[1]) - (a[1]-o[1])*(b[0]-o[0])
lo = []
for p in pts:
while len(lo) >= 2 and cross(lo[-2], lo[-1], p) <= 0:
lo.pop()
lo.append(p)
up = []
for p in reversed(pts):
while len(up) >= 2 and cross(up[-2], up[-1], p) <= 0:
up.pop()
up.append(p)
return lo[:-1] + up[:-1]
adjm = H.medial_adj(gg)
mpos = {m: mid(posGp[tuple(m)[0]], posGp[tuple(m)[1]]) for m in adjm}
hull = convex_hull(list(posGp.values()))
pos2v = {tuple(p): v for v, p in posGp.items()}
outer_face = {pos2v[tuple(p)] for p in hull}
print("geometric outer face (hull):", sorted(outer_face))
medges = []
seen = set()
for m in adjm:
for b in adjm[m]:
k = frozenset((m, b))
if k in seen:
continue
seen.add(k)
# skip the medial edge joining two edges of the outer face
if set(m) <= outer_face and set(b) <= outer_face:
continue
medges.append((m, b))
badM = crossings(medges, mpos)
print("M(G') crossings (outer-face medial triangle omitted):",
badM if badM else "NONE")
assert not badM
# ---- colourings ----------------------------------------------------------
col0, _ = H.canonical_coloring_explicit(gg, an2.level, outer, (0,), [1, 0, 2])
col1 = dict(col0)
adjm = H.medial_adj(gg)
comp = H.kempe_component(col1, adjm, H.e(0, 3), (1, 2))
H.switch(col1, comp, (1, 2))
third = H.diamond_condition(col1, quad)
col1[H.e(3, 4)] = third
# ---- drawing -------------------------------------------------------------
def lims(ax, pos):
xs = [p[0] for p in pos.values()]; ys = [p[1] for p in pos.values()]
ax.set_xlim(min(xs)-0.25, max(xs)+0.25); ax.set_ylim(min(ys)-0.25, max(ys)+0.3)
def draw_graph(ax, gr, pos, level=None, bold_cycle=None, shade_quad=None, wvert=None):
if shade_quad:
ax.add_patch(Polygon([pos[vv] for vv in shade_quad], closed=True,
color="#ffe2bf", zorder=0))
bold = set()
if bold_cycle:
for i in range(len(bold_cycle)):
bold.add(frozenset((bold_cycle[i], bold_cycle[(i+1) % len(bold_cycle)])))
for ed in gr.edges():
a, b = tuple(ed); pa, pb = pos[a], pos[b]
hot = ed in bold
ax.plot([pa[0], pb[0]], [pa[1], pb[1]],
color="#d62728" if hot else "#888888",
lw=2.8 if hot else 1.1, zorder=2)
for vv, p in pos.items():
c = "#d62728" if (wvert is not None and vv == wvert) else "#222222"
ax.plot(*p, "o", ms=18, mfc="white", mec=c, mew=1.7, zorder=5)
ax.annotate(str(vv), p, ha="center", va="center", fontsize=9,
fontweight="bold", color=c, zorder=6)
if level is not None:
ax.annotate(f"L{level[vv]}", p, textcoords="offset points",
xytext=(11, 10), fontsize=6.5, color="#999999", zorder=6)
def draw_medial(ax, pos, col, halo=None, restored=None):
for ed in gg.edges():
a, b = tuple(ed); pa, pb = pos[a], pos[b]
ax.plot([pa[0], pb[0]], [pa[1], pb[1]], color="#e3e3e3", lw=0.8, zorder=0)
for m, b in medges:
pa, pb = mpos[m], mpos[b]
ax.plot([pa[0], pb[0]], [pa[1], pb[1]], color="#c6c6c6", lw=0.8, zorder=1)
if restored is not None:
a, b = restored
ax.plot([pos[a][0], pos[b][0]], [pos[a][1], pos[b][1]], color="#d62728",
lw=1.8, ls=":", zorder=2)
ax.plot(*mid(pos[a], pos[b]), "s", color=PAL[col[H.e(a, b)]], ms=13,
mec="#d62728", mew=2.0, zorder=7)
halo = halo or set()
for m, p in mpos.items():
if m not in col:
continue
if m in halo:
ax.plot(*p, "o", color="#000000", ms=16, zorder=5)
ax.plot(*p, "o", color=PAL[col[m]], ms=10.5, mec="black", mew=0.8, zorder=6)
fig, axes = plt.subplots(1, 4, figsize=(19, 5.4))
for ax in axes:
ax.set_aspect("equal"); ax.axis("off"); ax.set_xlim(-3.2, 3.2); ax.set_ylim(-3.2, 3.2)
ax.set_aspect("equal"); ax.axis("off")
draw_graph(axes[0], g, posG, level=an.level, bold_cycle=ring)
lims(axes[0], posG)
axes[0].set_title("A. G (BFS levels from source triangle 0-1-2)\n"
"odd level-1 seam = 5-cycle 3-4-5-6-7 (red)", fontsize=9)
"odd level-1 seam = 5-cycle 3-4-5-6-7 (red)\n"
"verified straight-line planar embedding", fontsize=9)
draw_graph(axes[1], gg, posGp, level=an2.level, bold_cycle=ring2,
shade_quad=quad, wvert=w)
lims(axes[1], posGp)
axes[1].set_title("B. G' = G + diamond w=9 on edge (3,4)\n"
"seam now even 6-cycle; quad 3-0-4-8 shaded", fontsize=9)
quad_med = {H.e(quad[i], quad[(i+1) % 4]) for i in range(4)}
draw_medial(axes[2], gg, posGp, col0, halo=quad_med)
draw_medial(axes[2], posGp, col0, halo=quad_med)
lims(axes[2], posGp)
axes[2].set_title("C. M(G') canonical colour (phase 0, DFS order 1,0,2)\n"
"quad medials m(0,3)m(0,4)m(4,8)m(3,8) ALL =1 (haloed)\n"
"-> diamond_condition FAILS", fontsize=9)
draw_medial(axes[3], gg, posGp, col1, halo=comp, restored=(3, 4))
draw_medial(axes[3], posGp, col1, halo=comp, restored=(3, 4))
lims(axes[3], posGp)
axes[3].set_title("D. after {1,2}-Kempe switch on comp through m(0,3)\n"
"{(0,3),(3,7),(3,8),(3,9)} (haloed): quad -> 2,1,1,2\n"
f"remove w; restored edge (3,4)=square takes colour {third}",
fontsize=9)
fig.suptitle("Even-level-cycle programme, worked example (ring [3,5]+hub, 9 "
"vertices): one odd seam -> one diamond -> one Kempe switch -> "
"proper 3-colouring of M(G). Colours: 1=orange(0), 2=blue(1), "
@@ -38,7 +38,11 @@ The 14 triangular faces (one is the outer/unbounded face) are
(0,6,7) (0,5,6) (0,2,5) (0,1,2) (1,5,2) (5,8,6) (6,8,7)
```
and the 21 edges are the pairs appearing above. (Euler check: 9 21 + 14 = 2.)
The figure draws this with a Tutte-style concentric layout.
The figure draws G (and G, and the medial M(G) at edge midpoints) with a
straight-line planar embedding from `networkx.planar_layout`, each **verified
crossing-free** before rendering (the medial triangle of the geometric outer
face is omitted, since its midpoint-chords would otherwise cut across the
unbounded region).
## Step 2 — Pick the source and read off levels *(panel A)*
Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

After

Width:  |  Height:  |  Size: 188 KiB