Draw source-dual cut on a valid straight-line embedding

Store the combinatorial planar embedding in the result and lay out the
source graph with nx.planar_layout so no primal edges cross and each dual
node sits inside its own triangle, replacing the concentric layout that
produced crossings. Add a committed generate_full_walk.py that reproduces
the walk .md/.pdf/.png outputs, and regenerate the walk 1 and walk 2 dual
figures and PDFs (reports unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 03:59:22 -04:00
parent 4ba9ce47d1
commit af60c3b241
6 changed files with 223 additions and 39 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

After

Width:  |  Height:  |  Size: 243 KiB

@@ -0,0 +1,200 @@
"""Regenerate the ``full_walk`` contents (``.md`` report + ``_dual.png`` +
``_tires.png`` + combined ``.pdf``) for each configured medial tire cut walk.
Each walk fixes a reproducible source: a base maximal planar 5-connected graph
``random_maximal_planar_5_connected(n, seed)``, a chosen triangular face, the
deep-embedding cap vertex placed inside that face as the level source, and a
root entry tooth. The source-dual cut, its walk-distance labelling, and the
figures are all read off the deep embedding ``G'`` (whose dual-cut figure is now
drawn on a *valid straight-line embedding* of ``G'`` -- see
``medial_tire_dual_cut_experiment._straight_line_source_layout``).
Run with the repo venv::
../../../.venv/bin/python generate_full_walk.py # all walks
../../../.venv/bin/python generate_full_walk.py --walk 1 # just walk 1
"""
from __future__ import annotations
import argparse
import os
import sys
import networkx as nx
_HERE = os.path.dirname(os.path.abspath(__file__))
_EXP = os.path.dirname(_HERE)
sys.path.insert(0, _EXP)
import medial_tire_dual_cut_experiment as E # noqa: E402
from run_medial_tire_cut_experiment import ( # noqa: E402
random_maximal_planar_5_connected,
)
# --------------------------------------------------------------------------- #
# Walk configurations. Each is fully reproducible from (n, seed, face, entry).
# --------------------------------------------------------------------------- #
WALKS = [
{
"index": 1,
"title": "Full medial tire cut walk 1",
"n": 20,
"seed": 59,
"face": (8, 9, 19),
"entry": 2,
},
{
"index": 2,
"title": "Full medial tire cut walk 2",
"n": 20,
"seed": 2,
"face": (4, 12, 11),
"entry": 3,
},
]
def build_result(cfg):
"""Build the source-dual cut result dict for one walk configuration."""
G, graph_seed = random_maximal_planar_5_connected(
cfg["n"], cfg["seed"], min_connectivity=5)
G_prime, cap, depth = E.deep_embedding(G, cfg["face"])
result = E.medial_tire_dual_cut(G_prime, cap, cfg["entry"])
result["base_graph"] = G
result["chosen_face"] = tuple(cfg["face"])
result["cap_vertex"] = cap
result["deep_depth"] = depth
result["graph_seed"] = graph_seed
result["base_min_degree"] = min(dict(G.degree()).values())
result["base_connectivity"] = nx.node_connectivity(G)
result["min_degree"] = min(dict(G_prime.degree()).values())
result["connectivity"] = nx.node_connectivity(G_prime)
return result
def _tree_report(result):
"""``(is_tree, n_faces, n_edges, n_components, acyclic)`` for the cut."""
cut = E.dual_cut_graph(result)
n = cut.number_of_nodes()
e = cut.number_of_edges()
comps = nx.number_connected_components(cut)
return nx.is_tree(cut), n, e, comps, (e == n - comps)
def _distance_histogram(dist):
"""Histogram of dual faces by walk distance, as an ordered dict."""
hist = {}
for d in dist.values():
hist[d] = hist.get(d, 0) + 1
return {k: hist[k] for k in sorted(hist)}
def render_markdown(result, cfg):
"""The walk report in the committed ``.md`` format."""
G = result["G"]
base = result["base_graph"]
removed = result["removed_dual_edges"]
res = result["results"]
i = cfg["index"]
em = result["entry_medial_vertex"]
is_tree, n_faces, n_edges, comps, acyclic = _tree_report(result)
dist, root = E.dual_cut_distances(result)
root_face = result["faces"][root]
hist = _distance_histogram(dist)
max_dist = max(dist.values()) if dist else 0
lines = [
f"# {cfg['title']}",
"",
f"- base vertices: {base.number_of_nodes()}",
f"- deep-embedded vertices: {G.number_of_nodes()}",
f"- deep-embedded edges: {G.number_of_edges()}",
f"- graph seed: {result['graph_seed']}",
f"- deep-embedded minimum degree: {result['min_degree']}",
f"- chosen face: {result['chosen_face']}",
f"- chosen source cap vertex: {result['source']}",
f"- root entry tooth: e{result['entry_edge']} "
f"(apex medial vertex = level-1 edge {em})",
f"- recognised treads: {len(res)}",
f"- skipped treads: {result['skipped']}",
f"- removed source-dual edges: {len(removed)}",
f"- annular/cap cuts: {len(result['annular_cut_edges'])}",
f"- up-apex cuts: {len(result['apex_cut_edges'])}",
"",
f"- **source-dual cut is a tree: {is_tree}** ({n_faces} dual faces, "
f"{n_edges} edges, {comps} component(s), acyclic={acyclic})",
"",
"## Walk-distance labelling of the source-dual cut",
"",
"Each dual face (vertex of the source-dual cut) is labelled by its "
"distance, within the cut, from the **cap down tooth of the first "
f"entry**: the triangular face `{root_face}` = "
f"`{{source {result['source']}, edge {em}}}` (dual node {root}).",
"",
f"- maximum distance: {max_dist}",
f"- distance histogram (faces by distance): `{hist}`",
"",
f"- dual cut figure: `full_medial_tire_cut_walk_{i}_dual.png`",
f"- tire cut grid: `full_medial_tire_cut_walk_{i}_tires.png`",
f"- combined PDF: `full_medial_tire_cut_walk_{i}.pdf`",
"",
"| tread | depth | component | annular | up | singleton down | "
"bite apexes | entry | closing cuts | up-apex cuts | "
"shared/entry skipped |",
"|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|",
]
for idx, key in enumerate(sorted(res), start=1):
d, comp = key
rec = res[key]
g, bij, entry = rec["g"], rec["bij"], rec["entry_edge"]
up = len(g.up_edges)
apex = len(E.up_apex_cuts(g, entry, bij))
lines.append(
f"| T{idx} | {d} | {comp} | {g.n} | {up} | "
f"{len(g.singleton_down_edges)} | {len(g.bites)} | e{entry} | "
f"{len(rec['cuts'])} | {apex} | {up - apex} |")
lines += [
"",
"## Removed Source-Dual Edges",
"",
f"- annular/cap: `{sorted(result['annular_cut_edges'])}`",
f"- up apexes: `{sorted(result['apex_cut_edges'])}`",
"",
]
return "\n".join(lines)
def generate(cfg):
"""Build one walk and write its ``.md`` report and three figures."""
result = build_result(cfg)
i = cfg["index"]
stem = os.path.join(_HERE, f"full_medial_tire_cut_walk_{i}")
with open(f"{stem}.md", "w") as fh:
fh.write(render_markdown(result, cfg))
E.draw_png(result, f"{stem}_dual.png")
E.draw_tire_cuts_png(result, f"{stem}_tires.png")
E.draw_combined_pdf(result, f"{stem}.pdf")
print(f"walk {i}: wrote {stem}.md + _dual.png + _tires.png + .pdf")
def main():
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--walk", type=int, default=None,
help="regenerate only this walk index (default: all)")
args = parser.parse_args()
for cfg in WALKS:
if args.walk is None or cfg["index"] == args.walk:
generate(cfg)
if __name__ == "__main__":
main()
@@ -304,7 +304,7 @@ def medial_tire_dual_cut(G, source, entry_edge):
return { return {
"G": G, "M": M, "source": source, "entry_edge": entry_edge, "G": G, "M": M, "source": source, "entry_edge": entry_edge,
"entry_medial_vertex": entry_medial, "entry_medial_vertex": entry_medial,
"faces": faces, "outer_face": 0, "faces": faces, "embedding": emb, "outer_face": 0,
"levels": levels, "treads": treads, "tread_meta": tread_meta, "levels": levels, "treads": treads, "tread_meta": tread_meta,
"skipped": skipped, "skipped": skipped,
"results": results, "cap_cuts": cap_cuts, "cut_graph": cut_graph, "results": results, "cap_cuts": cap_cuts, "cut_graph": cut_graph,
@@ -415,40 +415,23 @@ def summary(result):
return "\n".join(lines) return "\n".join(lines)
def _radial_source_layout(G, source, levels): def _straight_line_source_layout(result):
"""Concentric ('onion') layout rooted at the cap ``source``: radius grows """A valid straight-line embedding of the source graph: vertex positions
with BFS level so the depth rings are actual circles, and each ring's with no edge crossings.
angular order is inherited from its lower-level neighbours to keep the
nesting legible. This matches the cap-source construction, where the BFS Computed from the *same* combinatorial planar embedding that defines
rings are exactly the plane-depth rings.""" ``result["faces"]`` (stored as ``result["embedding"]``), so every dual face
import math centroid lands strictly inside its triangle and the dual edges cross only
max_level = max(levels.values()) or 1 their own primal edge. ``nx.planar_layout`` realises that embedding as a
ring = defaultdict(list) crossing-free straight-line drawing via a canonical ordering; we tried a
for v, d in levels.items(): Tutte barycentric embedding instead, but the high-degree cap hub collapses
ring[d].append(v) it into an unreadable sliver, whereas the canonical drawing spreads the
angle = {source: 0.0} triangulation out legibly. Falls back to recomputing the embedding for
pos = {source: (0.0, 0.0)} older results that predate the stored ``embedding`` key."""
for d in range(1, max_level + 1): emb = result.get("embedding")
verts = ring[d] if emb is None:
prov = {} _faces, emb = triangular_faces(result["G"])
for v in verts: return nx.planar_layout(emb)
pa = [angle[w] for w in G.neighbors(v)
if levels.get(w) == d - 1 and w in angle]
if pa:
sx = sum(math.cos(a) for a in pa)
sy = sum(math.sin(a) for a in pa)
prov[v] = math.atan2(sy, sx)
else:
prov[v] = 0.0
verts.sort(key=lambda v: prov[v] % (2 * math.pi))
k = len(verts)
base = prov[verts[0]] if verts else 0.0
r = d / max_level
for i, v in enumerate(verts):
a = base + 2 * math.pi * i / k
angle[v] = a
pos[v] = (r * math.cos(a), r * math.sin(a))
return pos
def _draw_dual_cut_ax(ax, result): def _draw_dual_cut_ax(ax, result):
@@ -459,7 +442,7 @@ def _draw_dual_cut_ax(ax, result):
source = result["source"] source = result["source"]
entry_medial = result.get("entry_medial_vertex") entry_medial = result.get("entry_medial_vertex")
dist, root = dual_cut_distances(result) dist, root = dual_cut_distances(result)
pos_v = _radial_source_layout(G, source, result["levels"]) pos_v = _straight_line_source_layout(result)
def centroid(fi): def centroid(fi):
xs = [pos_v[u][0] for u in faces[fi]] xs = [pos_v[u][0] for u in faces[fi]]
@@ -542,13 +525,14 @@ def draw_png(result, path, scale=6.0):
"""Render the source-dual cut: dual nodes at face centroids, dual edges """Render the source-dual cut: dual nodes at face centroids, dual edges
drawn light gray where the cut removed them, labelled by missing count. drawn light gray where the cut removed them, labelled by missing count.
The source graph is laid out concentrically around the cap source so the The source graph is drawn on a valid straight-line embedding (see
BFS/plane-depth rings read as nested circles.""" ``_straight_line_source_layout``) so no primal edges cross and each dual
node sits inside its own triangle."""
import matplotlib import matplotlib
matplotlib.use("Agg") matplotlib.use("Agg")
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(7.6, 7.6)) fig, ax = plt.subplots(figsize=(12.0, 12.0))
_draw_dual_cut_ax(ax, result) _draw_dual_cut_ax(ax, result)
fig.tight_layout() fig.tight_layout()
fig.savefig(path, dpi=150) fig.savefig(path, dpi=150)