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; {(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. 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 Embedding: networkx planar_layout (canonical-ordering straight-line embedding),
seam-cycle order, so spokes read outward. Run with the repo venv python. 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 os, random
import numpy as np import numpy as np
import networkx as nx
import matplotlib import matplotlib
matplotlib.use("Agg") matplotlib.use("Agg")
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
@@ -29,82 +34,48 @@ import kempe_even_program_harness as H
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
PAL = {0: "#e6550d", 1: "#3182bd", 2: "#31a354"} # colours "1","2","3"(=2) 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 = {} def planar_pos(g):
for i, v in enumerate(outer): nxg = nx.Graph()
a = 90 - i * 120 nxg.add_nodes_from(g.rot)
pos[v] = (np.cos(np.radians(a)) * RAD[0], np.sin(np.radians(a)) * RAD[0]) for ed in g.edges():
m = len(ring_order) a, b = tuple(ed); nxg.add_edge(a, b)
for i, v in enumerate(ring_order): ok, _ = nx.check_planarity(nxg)
a = 90 + i * 360.0 / m assert ok, "graph not planar?!"
pos[v] = (np.cos(np.radians(a)) * RAD[1], np.sin(np.radians(a)) * RAD[1]) pos = nx.planar_layout(nxg)
for v in sorted(g.rot): pts = np.array([pos[v] for v in g.rot]); c = pts.mean(axis=0)
if v in pos: continue s = np.abs(pts - c).max()
pos[v] = (0.0, 0.0) # hub (level 2) return {v: ((pos[v][0]-c[0])/s, (pos[v][1]-c[1])/s) for v in g.rot}
return pos
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 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): # ---- build G and G' ------------------------------------------------------
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)
rng = random.Random(0) rng = random.Random(0)
g, outer = H.ring_triangulation([3, 5], 'hub', rng) g, outer = H.ring_triangulation([3, 5], 'hub', rng)
an = H.Analysis(g.copy(), outer) an = H.Analysis(g.copy(), outer)
ring = [k for k, c in an.seams if k == 1][0:1] and \ ring = [c for k, c in an.seams if k == 1][0]
[c for k, c in an.seams if k == 1][0]
posG = concentric(g, outer, an, ring)
prep = H._prep_gadgets(g.copy(), outer) prep = H._prep_gadgets(g.copy(), outer)
template, an_g, gadgets = prep template, an_g, gadgets = prep
@@ -112,39 +83,155 @@ gg = template.copy()
w, u, v, x, t = gg.insert_diamond(3, 4) w, u, v, x, t = gg.insert_diamond(3, 4)
an2 = H.Analysis(gg, outer) an2 = H.Analysis(gg, outer)
ring2 = [c for k, c in an2.seams if k == 1][0] ring2 = [c for k, c in an2.seams if k == 1][0]
posGp = concentric(gg, outer, an2, ring2) quad = H.quad_of(gg, w, u, v) # (3,0,4,8)
posGp[w] = mid(posGp[3], posGp[4])
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]) col0, _ = H.canonical_coloring_explicit(gg, an2.level, outer, (0,), [1, 0, 2])
col1 = dict(col0) col1 = dict(col0)
adjm = H.medial_adj(gg)
comp = H.kempe_component(col1, adjm, H.e(0, 3), (1, 2)) comp = H.kempe_component(col1, adjm, H.e(0, 3), (1, 2))
H.switch(col1, comp, (1, 2)) H.switch(col1, comp, (1, 2))
third = H.diamond_condition(col1, quad) third = H.diamond_condition(col1, quad)
col1[H.e(3, 4)] = third 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)) fig, axes = plt.subplots(1, 4, figsize=(19, 5.4))
for ax in axes: 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) 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" 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, draw_graph(axes[1], gg, posGp, level=an2.level, bold_cycle=ring2,
shade_quad=quad, wvert=w) shade_quad=quad, wvert=w)
lims(axes[1], posGp)
axes[1].set_title("B. G' = G + diamond w=9 on edge (3,4)\n" 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) "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)} 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" 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" "quad medials m(0,3)m(0,4)m(4,8)m(3,8) ALL =1 (haloed)\n"
"-> diamond_condition FAILS", fontsize=9) "-> 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" 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" "{(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}", f"remove w; restored edge (3,4)=square takes colour {third}",
fontsize=9) fontsize=9)
fig.suptitle("Even-level-cycle programme, worked example (ring [3,5]+hub, 9 " fig.suptitle("Even-level-cycle programme, worked example (ring [3,5]+hub, 9 "
"vertices): one odd seam -> one diamond -> one Kempe switch -> " "vertices): one odd seam -> one diamond -> one Kempe switch -> "
"proper 3-colouring of M(G). Colours: 1=orange(0), 2=blue(1), " "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) (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.) 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)* ## 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