diff --git a/papers/medial_tire_cuts/experiments/full_walk/full_medial_tire_cut_walk_1.pdf b/papers/medial_tire_cuts/experiments/full_walk/full_medial_tire_cut_walk_1.pdf index 0a2c808..aa2e691 100644 Binary files a/papers/medial_tire_cuts/experiments/full_walk/full_medial_tire_cut_walk_1.pdf and b/papers/medial_tire_cuts/experiments/full_walk/full_medial_tire_cut_walk_1.pdf differ diff --git a/papers/medial_tire_cuts/experiments/full_walk/full_medial_tire_cut_walk_1_dual.png b/papers/medial_tire_cuts/experiments/full_walk/full_medial_tire_cut_walk_1_dual.png index 429f63e..02f8f4c 100644 Binary files a/papers/medial_tire_cuts/experiments/full_walk/full_medial_tire_cut_walk_1_dual.png and b/papers/medial_tire_cuts/experiments/full_walk/full_medial_tire_cut_walk_1_dual.png differ diff --git a/papers/medial_tire_cuts/experiments/full_walk/full_medial_tire_cut_walk_2.pdf b/papers/medial_tire_cuts/experiments/full_walk/full_medial_tire_cut_walk_2.pdf index 6c4553b..369b90a 100644 Binary files a/papers/medial_tire_cuts/experiments/full_walk/full_medial_tire_cut_walk_2.pdf and b/papers/medial_tire_cuts/experiments/full_walk/full_medial_tire_cut_walk_2.pdf differ diff --git a/papers/medial_tire_cuts/experiments/full_walk/full_medial_tire_cut_walk_2_dual.png b/papers/medial_tire_cuts/experiments/full_walk/full_medial_tire_cut_walk_2_dual.png index 7b1b6c9..53ec587 100644 Binary files a/papers/medial_tire_cuts/experiments/full_walk/full_medial_tire_cut_walk_2_dual.png and b/papers/medial_tire_cuts/experiments/full_walk/full_medial_tire_cut_walk_2_dual.png differ diff --git a/papers/medial_tire_cuts/experiments/full_walk/generate_full_walk.py b/papers/medial_tire_cuts/experiments/full_walk/generate_full_walk.py new file mode 100644 index 0000000..4316135 --- /dev/null +++ b/papers/medial_tire_cuts/experiments/full_walk/generate_full_walk.py @@ -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() diff --git a/papers/medial_tire_cuts/experiments/medial_tire_dual_cut_experiment.py b/papers/medial_tire_cuts/experiments/medial_tire_dual_cut_experiment.py index 7d4b6df..b4583f3 100644 --- a/papers/medial_tire_cuts/experiments/medial_tire_dual_cut_experiment.py +++ b/papers/medial_tire_cuts/experiments/medial_tire_dual_cut_experiment.py @@ -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)