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:
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 285 KiB |
Binary file not shown.
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 {
|
||||
"G": G, "M": M, "source": source, "entry_edge": entry_edge,
|
||||
"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,
|
||||
"skipped": skipped,
|
||||
"results": results, "cap_cuts": cap_cuts, "cut_graph": cut_graph,
|
||||
@@ -415,40 +415,23 @@ def summary(result):
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _radial_source_layout(G, source, levels):
|
||||
"""Concentric ('onion') layout rooted at the cap ``source``: radius grows
|
||||
with BFS level so the depth rings are actual circles, and each ring's
|
||||
angular order is inherited from its lower-level neighbours to keep the
|
||||
nesting legible. This matches the cap-source construction, where the BFS
|
||||
rings are exactly the plane-depth rings."""
|
||||
import math
|
||||
max_level = max(levels.values()) or 1
|
||||
ring = defaultdict(list)
|
||||
for v, d in levels.items():
|
||||
ring[d].append(v)
|
||||
angle = {source: 0.0}
|
||||
pos = {source: (0.0, 0.0)}
|
||||
for d in range(1, max_level + 1):
|
||||
verts = ring[d]
|
||||
prov = {}
|
||||
for v in verts:
|
||||
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 _straight_line_source_layout(result):
|
||||
"""A valid straight-line embedding of the source graph: vertex positions
|
||||
with no edge crossings.
|
||||
|
||||
Computed from the *same* combinatorial planar embedding that defines
|
||||
``result["faces"]`` (stored as ``result["embedding"]``), so every dual face
|
||||
centroid lands strictly inside its triangle and the dual edges cross only
|
||||
their own primal edge. ``nx.planar_layout`` realises that embedding as a
|
||||
crossing-free straight-line drawing via a canonical ordering; we tried a
|
||||
Tutte barycentric embedding instead, but the high-degree cap hub collapses
|
||||
it into an unreadable sliver, whereas the canonical drawing spreads the
|
||||
triangulation out legibly. Falls back to recomputing the embedding for
|
||||
older results that predate the stored ``embedding`` key."""
|
||||
emb = result.get("embedding")
|
||||
if emb is None:
|
||||
_faces, emb = triangular_faces(result["G"])
|
||||
return nx.planar_layout(emb)
|
||||
|
||||
|
||||
def _draw_dual_cut_ax(ax, result):
|
||||
@@ -459,7 +442,7 @@ def _draw_dual_cut_ax(ax, result):
|
||||
source = result["source"]
|
||||
entry_medial = result.get("entry_medial_vertex")
|
||||
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):
|
||||
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
|
||||
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
|
||||
BFS/plane-depth rings read as nested circles."""
|
||||
The source graph is drawn on a valid straight-line embedding (see
|
||||
``_straight_line_source_layout``) so no primal edges cross and each dual
|
||||
node sits inside its own triangle."""
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
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)
|
||||
fig.tight_layout()
|
||||
fig.savefig(path, dpi=150)
|
||||
|
||||
Reference in New Issue
Block a user