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 {
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user