Add source cap cut to medial tire figures
This commit is contained in:
@@ -4,16 +4,18 @@ 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 the whole
|
||||
medial graph M(G) with every tire's cuts applied, on a Kamada--Kawai layout, the
|
||||
recognised tires highlighted and the rest of M(G) in grey.
|
||||
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 72 > panels.tex
|
||||
.venv/bin/python draw_medial_tire_cut.py -n 20 --seed 72 --whole > whole.tex
|
||||
.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
|
||||
@@ -24,18 +26,21 @@ 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) -> tuple[dict, list[str]]:
|
||||
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)
|
||||
result = run_experiment(n=n, seed=seed, min_degree=min_degree, attempts=attempts)
|
||||
panels = []
|
||||
for d in sorted(result["results"]):
|
||||
rec = result["results"][d]
|
||||
@@ -45,104 +50,228 @@ def tikz_panels(n: int, seed: int, scale: float = 1.6) -> tuple[dict, list[str]]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The whole medial graph: M(G) with all tire cuts applied.
|
||||
# Figure 3: the source graph and midpoint drawing of the whole medial graph.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _is_split(node) -> bool:
|
||||
return isinstance(node, tuple) and len(node) == 3 and node[1] in ("A", "B")
|
||||
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 _medial_layout(H: nx.Graph) -> dict:
|
||||
"""A Kamada--Kawai layout of the (planar) cut graph, normalised to the unit
|
||||
box. The two copies of a cut vertex have different neighbours, so the layout
|
||||
separates them automatically, showing the slit."""
|
||||
pos = nx.kamada_kawai_layout(H)
|
||||
xs = [p[0] for p in pos.values()]
|
||||
ys = [p[1] for p in pos.values()]
|
||||
cx, cy = 0.5 * (max(xs) + min(xs)), 0.5 * (max(ys) + min(ys))
|
||||
span = max(max(xs) - min(xs), max(ys) - min(ys)) or 1.0
|
||||
return {v: ((p[0] - cx) / span, (p[1] - cy) / span) for v, p in pos.items()}
|
||||
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 medial_tikz(result: dict, scale: float = 9.0) -> str:
|
||||
"""A TikZ ``tikzpicture`` of the whole medial graph M(G) with every tire's
|
||||
cuts applied. Tire teeth are coloured and carry their walk depth; annular
|
||||
medial vertices are black; medial vertices outside any recognised tire are
|
||||
grey; cut (split) vertices are drawn as separated copies."""
|
||||
H = result["cut_graph"]
|
||||
pos = _medial_layout(H)
|
||||
def _edge_label(edge) -> str:
|
||||
u, v = edge
|
||||
return f"${u}\\!{{-}}\\!{v}$"
|
||||
|
||||
# role of each medial vertex: annular / up / down / bite, and walk depth.
|
||||
annular = set()
|
||||
|
||||
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"]):
|
||||
g, bij = result["results"][d]["g"], result["results"][d]["bij"]
|
||||
annular.update(bij[f"a{k}"] for k in range(g.n))
|
||||
apex = {r["apex"]: (r["role"], r["walk"]) for r in result["labels"]}
|
||||
|
||||
def edge_of(node):
|
||||
return node[0] if _is_split(node) else node
|
||||
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(" med/.style={black!30, line width=0.3pt},")
|
||||
A(" grey/.style={circle, draw=black!45, fill=black!8, inner sep=0.9pt},")
|
||||
A(" ann/.style={circle, fill=black, inner sep=1.0pt},")
|
||||
A(" cutv/.style={circle, draw=red!75!black, fill=red!12, inner sep=1.0pt},")
|
||||
A(" upv/.style={circle, draw=blue!70!black, fill=blue!15, inner sep=1.3pt},")
|
||||
A(" downv/.style={circle, draw=red!70!black, fill=red!15, inner sep=1.3pt},")
|
||||
A(" bitev/.style={circle, draw=red!70!black, fill=red!35, inner sep=1.6pt},")
|
||||
A(" dlbl/.style={font=\\tiny\\bfseries, text=black, inner sep=0.5pt}]")
|
||||
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(node):
|
||||
x, y = pos[node]
|
||||
def pt_med(edge):
|
||||
x, y = medial_pos[edge]
|
||||
return f"({x:.3f},{y:.3f})"
|
||||
|
||||
for u, v in H.edges():
|
||||
A(f"\\draw[med] {pt(u)}--{pt(v)};")
|
||||
for node in H.nodes():
|
||||
mv = edge_of(node)
|
||||
if mv in apex:
|
||||
role, _ = apex[mv]
|
||||
style = {"up": "upv", "down": "downv", "bite": "bitev"}[role]
|
||||
elif mv in annular:
|
||||
style = "cutv" if _is_split(node) else "ann"
|
||||
else:
|
||||
style = "grey"
|
||||
A(f"\\node[{style}] at {pt(node)} {{}};")
|
||||
for node in H.nodes():
|
||||
mv = edge_of(node)
|
||||
if _is_split(node) or mv not in apex:
|
||||
continue
|
||||
x, y = pos[node]
|
||||
A(f"\\node[dlbl] at ({x:.3f},{y:.3f}) [yshift=4.5pt] {{{apex[mv][1]}}};")
|
||||
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)
|
||||
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 9.0))
|
||||
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)
|
||||
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)")
|
||||
|
||||
Reference in New Issue
Block a user