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()