287 lines
11 KiB
Python
287 lines
11 KiB
Python
"""Draw the walk-depth labelling and cut of a medial tire decomposition.
|
|
|
|
Paper-graphics companion to ``run_medial_tire_cut_experiment.py``: it imports
|
|
``run_experiment`` from there, runs the pipeline on a random maximal planar
|
|
graph, and emits TikZ. By default it draws one ``tikzpicture`` (walk-depth
|
|
labels + cut slits) per recognised full medial tire graph, using ``to_tikz``
|
|
from ``medial_tire_cut_labelling``. With ``--whole`` it instead draws a
|
|
two-panel Figure 3 graphic: the source graph with its source highlighted and
|
|
the whole medial graph M(G) drawn with every medial vertex at the midpoint of
|
|
its source edge and labelled by that source edge, with the full BFS-level chain
|
|
shown and the currently computed walk-depth labels and cuts marked.
|
|
|
|
This script only renders; the experiment itself draws nothing. Run with the
|
|
repo venv (networkx): ``.venv/bin/python``.
|
|
|
|
Examples:
|
|
.venv/bin/python draw_medial_tire_cut.py -n 20 --seed 59 > panels.tex
|
|
.venv/bin/python draw_medial_tire_cut.py -n 20 --seed 59 --whole > whole.tex
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import math
|
|
import os
|
|
import sys
|
|
|
|
import networkx as nx
|
|
import numpy as np
|
|
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, _HERE)
|
|
|
|
from run_medial_tire_cut_experiment import run_experiment # noqa: E402
|
|
from medial_tire_cut_labelling import to_tikz # noqa: E402
|
|
from tire_realization_analysis import triangular_faces # noqa: E402
|
|
|
|
|
|
def tikz_panels(n: int, seed: int, scale: float = 1.6,
|
|
min_degree: int = 5, attempts: int = 1000) -> tuple[dict, list[str]]:
|
|
"""Run the experiment and return ``(result, panels)``, one TikZ panel per
|
|
recognised tread, each showing that tread's walk-depth labelling and cut."""
|
|
result = run_experiment(n=n, seed=seed, min_degree=min_degree, attempts=attempts)
|
|
panels = []
|
|
for d in sorted(result["results"]):
|
|
rec = result["results"][d]
|
|
panels.append(to_tikz(rec["g"], depth=rec["depth"], cuts=rec["cuts"],
|
|
entry_edge=rec["entry_edge"], scale=scale))
|
|
return result, panels
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Figure 3: the source graph and midpoint drawing of the whole medial graph.
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def _source_layout(G: nx.Graph) -> dict[int, tuple[float, float]]:
|
|
"""Straight-line planar layout for the source graph, normalised to the unit
|
|
box and reused by the medial drawing."""
|
|
faces, _ = triangular_faces(G)
|
|
outer = list(faces[0])
|
|
outer_set = set(outer)
|
|
raw = {}
|
|
for i, v in enumerate(outer):
|
|
angle = math.radians(90.0 - i * 360.0 / len(outer))
|
|
raw[v] = np.array([math.cos(angle), math.sin(angle)], dtype=float)
|
|
|
|
inner = [v for v in sorted(G.nodes()) if v not in outer_set]
|
|
if inner:
|
|
idx = {v: i for i, v in enumerate(inner)}
|
|
n = len(inner)
|
|
A = np.zeros((n, n))
|
|
bx = np.zeros(n)
|
|
by = np.zeros(n)
|
|
for i, v in enumerate(inner):
|
|
nbrs = list(G.neighbors(v))
|
|
A[i, i] = 1.0
|
|
for w in nbrs:
|
|
if w in idx:
|
|
A[i, idx[w]] -= 1.0 / len(nbrs)
|
|
else:
|
|
bx[i] += raw[w][0] / len(nbrs)
|
|
by[i] += raw[w][1] / len(nbrs)
|
|
xs = np.linalg.solve(A, bx)
|
|
ys = np.linalg.solve(A, by)
|
|
for v in inner:
|
|
raw[v] = np.array([xs[idx[v]], ys[idx[v]]], dtype=float)
|
|
|
|
pts = np.array([raw[v] for v in G.nodes()], dtype=float)
|
|
center = 0.5 * (pts.max(axis=0) + pts.min(axis=0))
|
|
span = float(max(*(pts.max(axis=0) - pts.min(axis=0)), 1.0))
|
|
return {
|
|
v: tuple((raw[v] - center) / span)
|
|
for v in G.nodes()
|
|
}
|
|
|
|
|
|
def _edge_midpoint(pos: dict, edge) -> tuple[float, float]:
|
|
u, v = edge
|
|
ux, uy = pos[u]
|
|
vx, vy = pos[v]
|
|
return (0.5 * (ux + vx), 0.5 * (uy + vy))
|
|
|
|
|
|
def _edge_label(edge) -> str:
|
|
u, v = edge
|
|
return f"${u}\\!{{-}}\\!{v}$"
|
|
|
|
|
|
def _source_graph_tikz(result: dict, pos: dict, scale: float) -> str:
|
|
G, source = result["G"], result["source"]
|
|
L = []
|
|
A = L.append
|
|
A(f"\\begin{{tikzpicture}}[scale={scale},")
|
|
A(" sedge/.style={black!50, line width=0.35pt},")
|
|
A(" sv/.style={circle, draw=black!60, fill=white, inner sep=1.1pt},")
|
|
A(" srcv/.style={circle, draw=blue!75!black, fill=blue!18, line width=0.7pt, inner sep=1.8pt}]")
|
|
|
|
def pt(v):
|
|
x, y = pos[v]
|
|
return f"({x:.3f},{y:.3f})"
|
|
|
|
for u, v in sorted(G.edges()):
|
|
A(f"\\draw[sedge] {pt(u)}--{pt(v)};")
|
|
for v in sorted(G.nodes()):
|
|
style = "srcv" if v == source else "sv"
|
|
A(f"\\node[{style}] at {pt(v)} {{}};")
|
|
sx, sy = pos[source]
|
|
A(f"\\node[font=\\scriptsize, text=blue!70!black] at ({sx:.3f},{sy - 0.085:.3f}) {{source {source}}};")
|
|
A("\\end{tikzpicture}")
|
|
return "\n".join(L)
|
|
|
|
|
|
def _medial_midpoint_tikz(result: dict, pos: dict, scale: float) -> str:
|
|
"""Draw M(G) with each medial vertex at the midpoint of its source edge.
|
|
Every medial vertex is labelled by its source edge; same-level source edges
|
|
show the BFS level-chain tooth layers, and interlevel source edges show the
|
|
annular layers. Currently computed tire walk-depth labels and cut labels
|
|
are overlaid without moving the medial vertices away from their source
|
|
edges."""
|
|
G, M = result["G"], result["M"]
|
|
levels = nx.single_source_shortest_path_length(G, result["source"])
|
|
medial_pos = {edge: _edge_midpoint(pos, edge) for edge in M.nodes()}
|
|
|
|
apex_roles = {}
|
|
apex_walks = {}
|
|
for r in result["labels"]:
|
|
apex_roles[r["apex"]] = r["role"]
|
|
apex_walks.setdefault(r["apex"], []).append(r["walk"])
|
|
|
|
cut_records = []
|
|
cut_number = 1
|
|
for c in result.get("cap_cuts", []):
|
|
cut_records.append((cut_number, c["medial_vertex"], "cap", c))
|
|
cut_number += 1
|
|
for d in sorted(result["results"]):
|
|
rec = result["results"][d]
|
|
g, bij = rec["g"], rec["bij"]
|
|
for c in rec["cuts"]:
|
|
if c.vertex is None:
|
|
continue
|
|
cut_records.append((cut_number, bij[f"a{c.vertex}"], d, c))
|
|
cut_number += 1
|
|
|
|
L = []
|
|
A = L.append
|
|
A(f"\\begin{{tikzpicture}}[scale={scale},")
|
|
A(" base/.style={black!12, line width=0.25pt},")
|
|
A(" med/.style={black!38, line width=0.32pt},")
|
|
A(" annv/.style={circle, draw=black!70, fill=black!18, inner sep=1.0pt},")
|
|
A(" levone/.style={circle, draw=orange!75!black, fill=orange!20, inner sep=1.2pt},")
|
|
A(" levtwo/.style={circle, draw=violet!70!black, fill=violet!18, inner sep=1.2pt},")
|
|
A(" levthree/.style={circle, draw=teal!70!black, fill=teal!18, inner sep=1.2pt},")
|
|
A(" knownv/.style={circle, draw=red!70!black, fill=red!24, inner sep=1.5pt},")
|
|
A(" elbl/.style={font=\\tiny, text=black!70, inner sep=0.2pt},")
|
|
A(" dlbl/.style={font=\\tiny\\bfseries, text=black, inner sep=0.5pt},")
|
|
A(" cut/.style={red!80!black, line width=1.0pt},")
|
|
A(" cutlbl/.style={font=\\tiny, text=red!75!black}]")
|
|
|
|
def pt_med(edge):
|
|
x, y = medial_pos[edge]
|
|
return f"({x:.3f},{y:.3f})"
|
|
|
|
def pt_src(v):
|
|
x, y = pos[v]
|
|
return f"({x:.3f},{y:.3f})"
|
|
|
|
for u, v in sorted(result["G"].edges()):
|
|
A(f"\\draw[base] {pt_src(u)}--{pt_src(v)};")
|
|
for u, v in M.edges():
|
|
A(f"\\draw[med] {pt_med(u)}--{pt_med(v)};")
|
|
|
|
def chain_style(edge):
|
|
u, v = edge
|
|
lu, lv = levels[u], levels[v]
|
|
if lu != lv:
|
|
return "annv"
|
|
if edge in apex_roles:
|
|
return "knownv"
|
|
return {1: "levone", 2: "levtwo", 3: "levthree"}.get(lu, "annv")
|
|
|
|
for mv in sorted(M.nodes()):
|
|
A(f"\\node[{chain_style(mv)}] at {pt_med(mv)} {{}};")
|
|
for mv in sorted(M.nodes()):
|
|
x, y = medial_pos[mv]
|
|
A(f"\\node[elbl] at ({x:.3f},{y:.3f}) [yshift=-4.8pt] {{{_edge_label(mv)}}};")
|
|
for mv in sorted(apex_walks):
|
|
x, y = medial_pos[mv]
|
|
label = ",".join(str(w) for w in sorted(apex_walks[mv]))
|
|
A(f"\\node[dlbl] at ({x:.3f},{y:.3f}) [yshift=5.0pt] {{{label}}};")
|
|
for number, mv, _d, _cut in cut_records:
|
|
u, v = mv
|
|
ux, uy = pos[u]
|
|
vx, vy = pos[v]
|
|
mx, my = medial_pos[mv]
|
|
ex, ey = vx - ux, vy - uy
|
|
length = math.hypot(ex, ey) or 1.0
|
|
dx, dy = -0.035 * ey / length, 0.035 * ex / length
|
|
A(f"\\draw[cut] ({mx - dx:.3f},{my - dy:.3f})--({mx + dx:.3f},{my + dy:.3f});")
|
|
A(f"\\node[cutlbl] at ({mx + 2.4 * dx:.3f},{my + 2.4 * dy:.3f}) {{cut {number}}};")
|
|
A("\\end{tikzpicture}")
|
|
return "\n".join(L)
|
|
|
|
|
|
def medial_tikz(result: dict, scale: float = 7.0) -> str:
|
|
"""Two-panel TikZ for Figure 3: the source graph and the midpoint drawing of
|
|
its medial graph with all medial vertices labelled, plus the tire
|
|
walk-depth labels and cuts."""
|
|
pos = _source_layout(result["G"])
|
|
source = _source_graph_tikz(result, pos, scale=0.58 * scale)
|
|
medial = _medial_midpoint_tikz(result, pos, scale=scale)
|
|
return "\n".join([
|
|
"\\begin{tabular}{c}",
|
|
source,
|
|
"\\\\[-0.25ex]",
|
|
"{\\scriptsize source graph $G$}",
|
|
"\\\\[1.0ex]",
|
|
medial,
|
|
"\\\\[-0.25ex]",
|
|
"{\\scriptsize medial graph $M(G)$ at edge midpoints}",
|
|
"\\end{tabular}",
|
|
])
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("-n", type=int, default=20)
|
|
parser.add_argument("--seed", type=int, default=72)
|
|
parser.add_argument("--scale", type=float, default=1.6)
|
|
parser.add_argument("--min-degree", type=int, default=5,
|
|
help="reject random graphs below this minimum degree")
|
|
parser.add_argument("--attempts", type=int, default=1000,
|
|
help="number of consecutive seeds to try for --min-degree")
|
|
parser.add_argument("--whole", action="store_true",
|
|
help="draw the whole medial graph M(G) with all cuts, "
|
|
"instead of one panel per tread")
|
|
args = parser.parse_args()
|
|
|
|
if args.whole:
|
|
result = run_experiment(n=args.n, seed=args.seed,
|
|
min_degree=args.min_degree, attempts=args.attempts)
|
|
treads = sorted(result["results"])
|
|
print(f"% whole medial graph: n={args.n} seed={args.seed} "
|
|
f"graph_seed={result['graph_seed']} min_degree={result['min_degree']} "
|
|
f"source={result['source']} recognised treads={treads} "
|
|
f"|M(G)|={result['M'].number_of_nodes()}")
|
|
print(medial_tikz(result, scale=args.scale if args.scale != 1.6 else 7.0))
|
|
return
|
|
|
|
result, panels = tikz_panels(args.n, args.seed, scale=args.scale,
|
|
min_degree=args.min_degree, attempts=args.attempts)
|
|
treads = sorted(result["results"])
|
|
print(f"% medial tire cut: n={args.n} seed={args.seed} "
|
|
f"graph_seed={result['graph_seed']} min_degree={result['min_degree']} "
|
|
f"source={result['source']} recognised treads={treads}")
|
|
if not panels:
|
|
print("% (no recognised full medial tire graphs for this graph)")
|
|
for d, panel in zip(treads, panels):
|
|
g = result["results"][d]["g"]
|
|
print(f"% --- tread {d}: |A(T)|={g.n} word={g.tooth_word} "
|
|
f"bites={sorted(g.bites)} ---")
|
|
print(panel)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|