Update medial tire cut labelling

This commit is contained in:
2026-06-15 20:54:25 -04:00
parent 9ef231655e
commit 37a7ff0b00
11 changed files with 635 additions and 403 deletions
@@ -29,7 +29,10 @@ import networkx as nx
import numpy as np
_HERE = os.path.dirname(os.path.abspath(__file__))
_PAPER_DIR = os.path.dirname(_HERE)
_CUT_LIB = os.path.join(_PAPER_DIR, "lib")
sys.path.insert(0, _HERE)
sys.path.insert(0, _CUT_LIB)
from run_medial_tire_cut_experiment import run_experiment # noqa: E402
from medial_tire_cut_labelling import to_tikz # noqa: E402
@@ -0,0 +1,36 @@
# Full medial tire cut walk 1
- base vertices: 20
- deep-embedded vertices: 30
- deep-embedded edges: 84
- graph seed: 59
- deep-embedded minimum degree: 3
- chosen source cap vertex: 24
- recognised treads: 11
- skipped treads: [(0, 'only 0 up teeth')]
- removed source-dual edges: 29
- annular/cap cuts: 12
- up-apex cuts: 17
- dual cut figure: `full_medial_tire_cut_walk_1_dual.png`
- tire cut grid: `full_medial_tire_cut_walk_1_tires.png`
- combined PDF: `full_medial_tire_cut_walk_1.pdf`
| tread | depth | component | annular | up | singleton down | bite apexes | entry | closing cuts | up-apex cuts | shared/entry skipped |
|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|
| T1 | 1 | 0 | 9 | 3 | 6 | 0 | e2 | 1 | 2 | 1 |
| T2 | 2 | 0 | 17 | 6 | 11 | 0 | e15 | 1 | 5 | 1 |
| T3 | 3 | 0 | 3 | 3 | 0 | 0 | e1 | 1 | 2 | 1 |
| T4 | 3 | 1 | 3 | 3 | 0 | 0 | e1 | 1 | 2 | 1 |
| T5 | 3 | 2 | 3 | 3 | 0 | 0 | e1 | 1 | 2 | 1 |
| T6 | 3 | 3 | 3 | 3 | 0 | 0 | e0 | 1 | 2 | 1 |
| T7 | 3 | 4 | 3 | 3 | 0 | 0 | e1 | 1 | 2 | 1 |
| T8 | 3 | 5 | 3 | 3 | 0 | 0 | e1 | 1 | 2 | 1 |
| T9 | 3 | 6 | 3 | 3 | 0 | 0 | e0 | 1 | 2 | 1 |
| T10 | 3 | 7 | 3 | 3 | 0 | 0 | e2 | 1 | 2 | 1 |
| T11 | 3 | 8 | 3 | 3 | 0 | 0 | e2 | 1 | 2 | 1 |
## Removed Source-Dual Edges
- annular/cap: `[(0, 20), (0, 21), (1, 6), (7, 8), (11, 25), (11, 26), (12, 27), (15, 29), (16, 28), (19, 24), (22, 5), (23, 4)]`
- up apexes: `[(0, 5), (1, 5), (2, 3), (2, 7), (4, 5), (8, 9), (10, 3), (10, 18), (11, 16), (12, 15), (12, 16), (13, 14), (13, 15), (14, 4), (16, 17), (18, 6), (19, 9)]`
Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

@@ -1,387 +1,16 @@
"""Walk-depth labelling and cut of a full medial tire graph.
Implements the procedure of Definition 2.1 ("Walk-depth labelling and cut") of
the *Medial Tire Cuts* paper:
1. Pick an arbitrary up tooth, the entry tooth; it has walk depth d.
2. Traverse all teeth bounding the inner face incident to the entry tooth
clockwise until reaching the entry tooth, incrementing the walk depth by 1
for each tooth traversed.
3. On reaching the last tooth in the face, perform a cut by duplicating the
annular vertex at which the traversal closes (the annular vertex shared by
the last tooth and the closing tooth).
4. Find the tooth t of highest walk depth that is a member of a bite.
5. If t is incident to a face F with unlabelled teeth, traverse the teeth of F
starting from t in the direction of the unlabelled tooth incident to t
(sharing an annular vertex), incrementing the walk depth as you go.
6. Repeat steps 3-5 until all teeth are labelled.
The full medial tire graph model (annular cycle A(T), up/down teeth, bites, the
auxiliary plane graph B(T) and its inner faces) is the one from the companion
``full_medial_tire_generator.py`` of the medial tire decompositions paper, which
we import.
Teeth are identified with the annular edges that carry them: edge i sits on the
annular vertices a_i and a_{(i+1) mod n} and carries exactly one tooth. A bite
(i, j) carries two teeth, one on edge i and one on edge j, that share the bite
apex p. The inner non-tooth faces of B(T) are the root face (written ``None``)
and one inner-gap face per bite.
"""
"""Compatibility wrapper for the medial tire cut labelling script."""
from __future__ import annotations
import argparse
import math
import os
import sys
# Import the full medial tire model from the companion paper's experiments.
_GEN_DIR = os.path.normpath(os.path.join(
os.path.dirname(__file__), "..", "..",
"medial_tire_decompositions_of_plane_triangulations", "experiments",
))
sys.path.insert(0, _GEN_DIR)
PAPER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LIB_DIR = os.path.join(PAPER_DIR, "lib")
if LIB_DIR not in sys.path:
sys.path.insert(0, LIB_DIR)
from full_medial_tire_generator import ( # noqa: E402
FullMedialTireGraph,
has_incident_bite,
innermost_bite,
satisfies_bite_face_condition,
)
Face = "tuple[int, int] | None" # a bite (i, j), or None for the root face
# ---------------------------------------------------------------------------
# Face structure of B(T).
# ---------------------------------------------------------------------------
def parent_face(graph: FullMedialTireGraph, bite: tuple[int, int]) -> Face:
"""The face directly enclosing ``bite``: the minimal-span bite strictly
containing it, or the root face ``None``."""
i, j = bite
enclosing = [b for b in graph.bites if b[0] < i and b[1] > j]
if not enclosing:
return None
return min(enclosing, key=lambda b: b[1] - b[0])
def door_bite(graph: FullMedialTireGraph, edge: int) -> tuple[int, int] | None:
"""The bite that ``edge`` is a door of (i.e. a bite edge), or None."""
for b in graph.bites:
if edge in b:
return b
return None
def faces_bordered(graph: FullMedialTireGraph, edge: int) -> list[Face]:
"""The inner non-tooth faces whose boundary the tooth on ``edge`` lies on.
A bite door borders two faces (its bite's gap and that bite's parent); any
other tooth borders the single face directly containing its edge.
"""
bite = door_bite(graph, edge)
if bite is not None:
return [bite, parent_face(graph, bite)]
return [innermost_bite(edge, graph.bites)]
def face_boundary(graph: FullMedialTireGraph, face: Face) -> list[int]:
"""The teeth (annular edges) bounding ``face``, in clockwise cyclic order.
Clockwise is increasing edge index. For the root face the boundary is read
around the whole cycle; for a bite gap (i, j) it is read along the arc
i, i+1, ..., j and closes through the bite apex. Edges enclosed by a child
bite are skipped (they belong to the child's gap face).
"""
n = graph.n
arc = range(n) if face is None else range(face[0], face[1] + 1)
return [k for k in arc if face in faces_bordered(graph, k)]
def all_faces(graph: FullMedialTireGraph) -> list[Face]:
return [None] + sorted(graph.bites)
def shared_annular_vertex(graph: FullMedialTireGraph, e1: int, e2: int) -> int | None:
"""The annular vertex a_k shared by edges ``e1`` and ``e2``, or None."""
n = graph.n
common = {e1, (e1 + 1) % n} & {e2, (e2 + 1) % n}
return next(iter(common)) if common else None
# ---------------------------------------------------------------------------
# The walk-depth labelling and cut.
# ---------------------------------------------------------------------------
class Cut:
"""A cut performed when a face traversal closes: the duplicated annular
vertex, together with the last labelled tooth and the closing tooth that
share it, and the face being closed."""
__slots__ = ("vertex", "last_tooth", "closing_tooth", "face", "order")
def __init__(self, vertex, last_tooth, closing_tooth, face, order):
self.vertex = vertex
self.last_tooth = last_tooth
self.closing_tooth = closing_tooth
self.face = face
self.order = order
def __repr__(self):
f = "root" if self.face is None else f"bite{self.face}"
return (f"Cut(order={self.order}, a{self.vertex}, "
f"last=e{self.last_tooth}, closing=e{self.closing_tooth}, face={f})")
def label_and_cut(graph: FullMedialTireGraph, entry_edge: int,
start_depth: int = 0) -> tuple[dict[int, int], list[Cut]]:
"""Run the procedure starting from up tooth ``entry_edge``.
Returns ``(depth, cuts)`` where ``depth`` maps each annular edge (tooth) to
its walk depth, and ``cuts`` is the list of cuts in the order performed.
"""
if graph.tooth_word[entry_edge] != "U":
raise ValueError(f"entry edge {entry_edge} is not an up tooth")
depth: dict[int, int] = {}
cuts: list[Cut] = []
counter = start_depth
def traverse(face: Face, start_edge: int, is_entry: bool) -> None:
nonlocal counter
boundary = face_boundary(graph, face)
m = len(boundary)
pos = boundary.index(start_edge)
if is_entry:
depth[start_edge] = counter
counter += 1
direction = +1
else:
# head toward the unlabelled tooth incident to the door t
direction = +1 if boundary[(pos + 1) % m] not in depth else -1
last_new = start_edge
i = pos
while True:
i = (i + direction) % m
edge = boundary[i]
if edge in depth: # the closing tooth
cuts.append(Cut(
vertex=shared_annular_vertex(graph, last_new, edge),
last_tooth=last_new, closing_tooth=edge,
face=face, order=len(cuts),
))
return
depth[edge] = counter
counter += 1
last_new = edge
# Steps 1-3: the entry face.
traverse(innermost_bite(entry_edge, graph.bites), entry_edge, is_entry=True)
# Steps 4-6: descend (or ascend) through bites, deepest first. The root
# face is ``None``, so we use a distinct sentinel for "no unlabelled face".
_MISSING = object()
while len(depth) < graph.n:
labelled_bite_teeth = sorted(
(e for e in depth if door_bite(graph, e) is not None),
key=lambda e: depth[e], reverse=True,
)
for t in labelled_bite_teeth:
target = next((F for F in faces_bordered(graph, t)
if any(e not in depth for e in face_boundary(graph, F))),
_MISSING)
if target is not _MISSING:
traverse(target, t, is_entry=False)
break
else:
break # no progress possible
return depth, cuts
# ---------------------------------------------------------------------------
# TikZ rendering.
# ---------------------------------------------------------------------------
def _coords(graph: FullMedialTireGraph,
r_ann=1.0, r_up=1.46, r_down=0.60) -> dict[str, tuple[float, float]]:
n = graph.n
def ang(k): # a_0 at the top, increasing k clockwise
return math.radians(90.0 - k * 360.0 / n)
def edge_mid_dir(i): # angle of the bisector of edge i's two endpoints
a0, a1 = ang(i), ang((i + 1) % n)
return math.atan2(math.sin(a0) + math.sin(a1), math.cos(a0) + math.cos(a1))
pos = {f"a{k}": (r_ann * math.cos(ang(k)), r_ann * math.sin(ang(k)))
for k in range(n)}
for i in graph.up_edges:
a = edge_mid_dir(i)
pos[f"u{i}"] = (r_up * math.cos(a), r_up * math.sin(a))
for i in graph.singleton_down_edges:
a = edge_mid_dir(i)
pos[f"d{i}"] = (r_down * math.cos(a), r_down * math.sin(a))
for (i, j) in graph.bites:
pts = [pos[f"a{i}"], pos[f"a{(i + 1) % n}"],
pos[f"a{j}"], pos[f"a{(j + 1) % n}"]]
cx = sum(p[0] for p in pts) / 4.0
cy = sum(p[1] for p in pts) / 4.0
pos[f"p{i}_{j}"] = (0.9 * cx, 0.9 * cy)
return pos
def _edge_midpoint(pos, graph, edge):
n = graph.n
a, b = pos[f"a{edge}"], pos[f"a{(edge + 1) % n}"]
return (0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1]))
def to_tikz(graph: FullMedialTireGraph,
depth: dict[int, int] | None = None,
cuts: list[Cut] | None = None,
entry_edge: int | None = None,
scale: float = 2.2) -> str:
"""A standalone ``tikzpicture`` for ``graph``; if ``depth`` is given, draw
the walk-depth labels and (with ``cuts``) the cut marks."""
pos = _coords(graph)
n = graph.n
L = []
A = L.append
A(f"\\begin{{tikzpicture}}[scale={scale},")
A(" ann/.style={circle, fill=black, inner sep=1.0pt},")
A(" upv/.style={circle, draw=blue!70!black, fill=blue!12, inner sep=1.4pt},")
A(" downv/.style={circle, draw=red!70!black, fill=red!12, inner sep=1.4pt},")
A(" bitev/.style={circle, draw=red!70!black, fill=red!32, inner sep=1.7pt},")
A(" cyc/.style={black, line width=1.0pt},")
A(" tth/.style={black!55, line width=0.4pt},")
A(" lbl/.style={font=\\scriptsize},")
A(" dlbl/.style={font=\\scriptsize\\bfseries, text=black},")
A(" cut/.style={red!80!black, line width=1.3pt},")
A(" cutlbl/.style={font=\\tiny, text=red!75!black}]")
def pt(name):
x, y = pos[name]
return f"({x:.3f},{y:.3f})"
# annular cycle
cyc = "--".join(pt(f"a{k}") for k in range(n)) + "--cycle"
A(f"\\draw[cyc] {cyc};")
# spokes
for i in graph.up_edges:
A(f"\\draw[tth] {pt(f'u{i}')}--{pt(f'a{i}')} {pt(f'u{i}')}--{pt(f'a{(i+1)%n}')};")
for i in graph.singleton_down_edges:
A(f"\\draw[tth] {pt(f'd{i}')}--{pt(f'a{i}')} {pt(f'd{i}')}--{pt(f'a{(i+1)%n}')};")
for (i, j) in graph.bites:
apex = f"p{i}_{j}"
for e in (i, j):
A(f"\\draw[tth] {pt(apex)}--{pt(f'a{e}')} {pt(apex)}--{pt(f'a{(e+1)%n}')};")
# vertices
for k in range(n):
A(f"\\node[ann] at {pt(f'a{k}')} {{}};")
for i in graph.up_edges:
A(f"\\node[upv] at {pt(f'u{i}')} {{}};")
for i in graph.singleton_down_edges:
A(f"\\node[downv] at {pt(f'd{i}')} {{}};")
for (i, j) in sorted(graph.bites):
A(f"\\node[bitev] at {pt(f'p{i}_{j}')} {{}};")
# walk-depth labels: placed along the spoke from apex toward the edge mid
if depth is not None:
for edge in range(n):
apex = graph.apex_of_edge(edge)
ax, ay = pos[apex]
mx, my = _edge_midpoint(pos, graph, edge)
f = 0.5
lx, ly = ax + f * (mx - ax), ay + f * (my - ay)
A(f"\\node[dlbl] at ({lx:.3f},{ly:.3f}) {{{depth[edge]}}};")
# cut marks: a short red slit across the duplicated annular vertex
if cuts:
for c in cuts:
if c.vertex is None:
continue
vx, vy = pos[f"a{c.vertex}"]
rad = math.atan2(vy, vx)
dx, dy = 0.16 * math.cos(rad), 0.16 * math.sin(rad)
A(f"\\draw[cut] ({vx-dx:.3f},{vy-dy:.3f})--({vx+dx:.3f},{vy+dy:.3f});")
lx, ly = vx + 0.30 * math.cos(rad), vy + 0.30 * math.sin(rad)
A(f"\\node[cutlbl] at ({lx:.3f},{ly:.3f}) {{cut {c.order+1}}};")
if entry_edge is not None:
ex, ey = pos[graph.apex_of_edge(entry_edge)]
rad = math.atan2(ey, ex)
tx, ty = ex + 0.34 * math.cos(rad), ey + 0.34 * math.sin(rad)
A(f"\\node[lbl, text=blue!60!black] at ({tx:.3f},{ty:.3f}) {{entry}};")
A("\\end{tikzpicture}")
return "\n".join(L)
# ---------------------------------------------------------------------------
# Worked example and CLI.
# ---------------------------------------------------------------------------
def worked_example() -> FullMedialTireGraph:
"""A clean 8-tooth piece: one bite (0,4), three down singletons 1,2,3 in its
gap, three up teeth 5,6,7 in the root face."""
return FullMedialTireGraph(n=8, tooth_word="DDDDDUUU", bites=frozenset({(0, 4)}))
def _check(graph: FullMedialTireGraph) -> None:
assert not has_incident_bite(graph.bites, graph.n), "bite uses incident edges"
assert satisfies_bite_face_condition(graph.tooth_word, graph.bites), \
"violates the bite-face condition"
assert graph.tooth_word.count("U") >= 3, "fewer than three up teeth"
def _describe(graph, depth, cuts) -> str:
lines = ["edge type walk-depth"]
for e in range(graph.n):
t = graph.tooth_word[e]
kind = {"U": "up"}.get(t, "down")
if door_bite(graph, e) is not None:
kind = "bite"
lines.append(f" e{e} {kind:<5} {depth[e]}")
lines.append("cuts (in order):")
for c in cuts:
f = "root" if c.face is None else f"bite{c.face}"
lines.append(f" cut {c.order+1}: duplicate a{c.vertex} "
f"(closing tooth e{c.closing_tooth} of {f})")
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--entry", default="u5",
help="entry up tooth, as an edge index or apex name like u5")
parser.add_argument("--start-depth", type=int, default=0)
parser.add_argument("--tikz", choices=["plain", "labelled", "both"],
help="emit TikZ for the worked example")
args = parser.parse_args()
entry = args.entry
edge = int(entry[1:]) if isinstance(entry, str) and entry.startswith("u") else int(entry)
graph = worked_example()
_check(graph)
depth, cuts = label_and_cut(graph, edge, start_depth=args.start_depth)
if args.tikz == "plain":
print(to_tikz(graph))
elif args.tikz == "labelled":
print(to_tikz(graph, depth=depth, cuts=cuts, entry_edge=edge))
elif args.tikz == "both":
print("% --- plain ---")
print(to_tikz(graph))
print("% --- labelled + cut ---")
print(to_tikz(graph, depth=depth, cuts=cuts, entry_edge=edge))
else:
print(f"worked example: n={graph.n} word={graph.tooth_word} "
f"bites={sorted(graph.bites)} entry=e{edge}")
print(_describe(graph, depth, cuts))
from medial_tire_cut_labelling import main
if __name__ == "__main__":
@@ -50,8 +50,11 @@ _HERE = os.path.dirname(os.path.abspath(__file__))
_MTD = os.path.normpath(os.path.join(
_HERE, "..", "..",
"medial_tire_decompositions_of_plane_triangulations", "experiments"))
sys.path.insert(0, _MTD)
_PAPER_DIR = os.path.dirname(_HERE)
_CUT_LIB = os.path.join(_PAPER_DIR, "lib")
sys.path.insert(0, _HERE)
sys.path.insert(0, _MTD)
sys.path.insert(0, _CUT_LIB)
from tire_realization_analysis import ( # noqa: E402
ekey, extract_tread, medial_graph, medial_tire_facemodel,
@@ -61,6 +64,7 @@ from run_medial_tire_cut_experiment import ( # noqa: E402
_assemble_cut_graph, _cap_cut, _label_treads,
random_maximal_planar_min_degree,
)
from medial_tire_cut_labelling import up_apex_cuts # noqa: E402
# --------------------------------------------------------------------------- #
@@ -204,16 +208,13 @@ def annular_cut_edges(results, cap_cuts):
def up_apex_cut_edges(results):
"""Primal edges whose dual edge the apex duplications remove: the apex
medial vertex of every (singleton) up tooth across all treads, except the
entry tooth of each tread (its apex is not duplicated)."""
medial vertex of every up tooth across all treads, except each tread's
entry tooth and any vertex that is the shared apex of two up teeth."""
removed = set()
for key in sorted(results):
g, bij = results[key]["g"], results[key]["bij"]
entry = results[key]["entry_edge"]
for i in g.up_edges:
if i == entry:
continue
removed.add(bij[f"u{i}"])
removed.update(up_apex_cuts(g, entry, bij=bij).values())
return removed
@@ -412,16 +413,8 @@ def _radial_source_layout(G, source, levels):
return pos
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."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
def _draw_dual_cut_ax(ax, result):
"""Draw the source-dual cut on an existing Matplotlib axis."""
G, faces, dual = result["G"], result["faces"], result["dual"]
removed = result["removed_dual_edges"]
missing = result["dual_face_missing"]
@@ -435,7 +428,6 @@ def draw_png(result, path, scale=6.0):
return (sum(xs) / 3.0, sum(ys) / 3.0)
pos = {fi: centroid(fi) for fi in dual.nodes()}
fig, ax = plt.subplots(figsize=(7.6, 7.6))
# primal (source) graph, faint, for orientation
for u, v in G.edges():
ax.plot([pos_v[u][0], pos_v[v][0]], [pos_v[u][1], pos_v[v][1]],
@@ -491,6 +483,20 @@ def draw_png(result, path, scale=6.0):
f"max {result['max_missing']}", fontsize=9)
ax.set_aspect("equal")
ax.axis("off")
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."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(7.6, 7.6))
_draw_dual_cut_ax(ax, result)
fig.tight_layout()
fig.savefig(path, dpi=150)
plt.close(fig)
@@ -583,10 +589,8 @@ def _draw_tread(ax, g, depth, cuts, entry_edge, title):
f"cut {c.order + 1}", fontsize=6, color="#cc2020",
ha="center", va="center", zorder=5)
# up-tooth apex duplications (slit tangential, across the apex marker);
# the entry tooth's apex is not duplicated
for i in g.up_edges:
if i == entry_edge:
continue
# entry and shared up-apex vertices are not duplicated.
for i in up_apex_cuts(g, entry_edge):
vx, vy = pos[f"u{i}"]
rad = math.atan2(vy, vx)
tx, ty = -math.sin(rad), math.cos(rad) # tangential
@@ -635,6 +639,116 @@ def draw_tire_cuts_png(result, path):
plt.close(fig)
def _tire_tree_edges(result):
"""Parent-child edges between recognised tires, using chained entry apexes."""
res = result["results"]
edges = []
for child in sorted(res):
d, _comp = child
if d == min(k[0] for k in res):
continue
cg, cbij = res[child]["g"], res[child]["bij"]
entry = res[child]["entry_edge"]
child_apex = cbij[f"u{entry}"]
for parent in sorted(k for k in res if k[0] == d - 1):
pg, pbij = res[parent]["g"], res[parent]["bij"]
if any(pbij[pg.apex_of_edge(e)] == child_apex for e in pg.down_edges):
edges.append((parent, child))
break
return edges
def _draw_tire_tree_ax(ax, result):
"""Compact tire-tree panel, styled like the decomposition overview."""
res = result["results"]
keys = sorted(res)
if not keys:
ax.text(0.5, 0.5, "no recognised tires", ha="center", va="center")
ax.axis("off")
return
by_depth = defaultdict(list)
for key in keys:
by_depth[key[0]].append(key)
pos = {}
depths = sorted(by_depth)
for row, d in enumerate(depths):
group = by_depth[d]
for i, key in enumerate(group):
x = i - (len(group) - 1) / 2
y = -1.25 * row
pos[key] = (x, y)
for parent, child in _tire_tree_edges(result):
x1, y1 = pos[parent]
x2, y2 = pos[child]
ax.plot([x1, x2], [y1, y2], color="0.45", lw=1.0, zorder=1)
for idx, key in enumerate(keys, start=1):
d, comp = key
rec = res[key]
g = rec["g"]
x, y = pos[key]
ax.scatter([x], [y], s=300, marker="s", facecolor="#eef3fa",
edgecolor="#3a6ea5", linewidth=1.0, zorder=2)
ax.text(
x, y,
f"T{idx}\n{d}.{comp}\n|A|={g.n}",
ha="center", va="center", fontsize=6, zorder=3,
)
ax.set_title("tire tree", fontsize=10)
ax.margins(x=0.08, y=0.18)
ax.axis("off")
def draw_combined_pdf(result, path):
"""Draw the source-dual cut, tire tree, and all tire panels in one PDF."""
import math
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
keys = sorted(result["results"])
if not keys:
raise ValueError("no recognised tires to draw")
fig = plt.figure(figsize=(17, 10))
spec = fig.add_gridspec(2, 2, width_ratios=[1.05, 1.35],
height_ratios=[1.1, 0.9])
ax_dual = fig.add_subplot(spec[0, 0])
ax_tree = fig.add_subplot(spec[1, 0])
_draw_dual_cut_ax(ax_dual, result)
_draw_tire_tree_ax(ax_tree, result)
cols = min(4, len(keys))
rows = math.ceil(len(keys) / cols)
tire_spec = spec[:, 1].subgridspec(rows, cols, wspace=0.18, hspace=0.28)
for i, key in enumerate(keys):
ax = fig.add_subplot(tire_spec[i // cols, i % cols])
d, comp = key
rec = result["results"][key]
g = rec["g"]
title = (
f"T{i + 1} d={d}.{comp} |A|={g.n} {g.tooth_word}\n"
f"entry=e{rec['entry_edge']} start={rec['start_depth']} "
f"closing={len(rec['cuts'])} apex={len(up_apex_cuts(g, rec['entry_edge'], rec['bij']))}"
)
_draw_tread(ax, g, rec["depth"], rec["cuts"], rec["entry_edge"], title)
for i in range(len(keys), rows * cols):
ax = fig.add_subplot(tire_spec[i // cols, i % cols])
ax.axis("off")
base = result.get("base_graph")
fig.suptitle(
"Full medial tire cut walk: "
f"base n={base.number_of_nodes() if base is not None else '?'}; "
f"deep n={result['G'].number_of_nodes()}; source={result['source']}; "
f"root entry=e{result['entry_edge']}; removed dual edges={len(result['removed_dual_edges'])}",
fontsize=13,
)
fig.subplots_adjust(left=0.03, right=0.99, top=0.90, bottom=0.04,
wspace=0.10, hspace=0.16)
fig.savefig(path)
plt.close(fig)
def draw_cap_png(result, path):
"""Render tread 0, the source cap: a wheel with the source at the hub, its
link cycle as the rim, the cap triangles (down teeth) filled, and the cap
@@ -726,6 +840,8 @@ def main():
help="render each full medial tire cut to PNG")
parser.add_argument("--cap-png", metavar="PATH",
help="render tread 0 (the source cap) to PNG")
parser.add_argument("--pdf", metavar="PATH",
help="render dual, tire tree, and tire cuts in one PDF")
args = parser.parse_args()
rng = random.Random(args.seed)
@@ -759,6 +875,9 @@ def main():
if args.cap_png:
draw_cap_png(result, args.cap_png)
print(f"wrote {args.cap_png}")
if args.pdf:
draw_combined_pdf(result, args.pdf)
print(f"wrote {args.pdf}")
if __name__ == "__main__":
@@ -49,8 +49,11 @@ _HERE = os.path.dirname(os.path.abspath(__file__))
_MTD = os.path.normpath(os.path.join(
_HERE, "..", "..",
"medial_tire_decompositions_of_plane_triangulations", "experiments"))
sys.path.insert(0, _MTD)
_PAPER_DIR = os.path.dirname(_HERE)
_CUT_LIB = os.path.join(_PAPER_DIR, "lib")
sys.path.insert(0, _HERE)
sys.path.insert(0, _MTD)
sys.path.insert(0, _CUT_LIB)
from tire_realization_analysis import ( # noqa: E402
ekey, extract_tread, medial_graph, medial_tire_facemodel,
+1
View File
@@ -0,0 +1 @@
"""Reusable medial tire cut helpers."""
@@ -0,0 +1,429 @@
"""Walk-depth labelling and cut of a full medial tire graph.
Implements the procedure of Definition 2.1 ("Walk-depth labelling and cut") of
the *Medial Tire Cuts* paper:
1. Pick an arbitrary up tooth, the entry tooth; it has walk depth d.
2. Traverse all teeth bounding the inner face incident to the entry tooth
clockwise until reaching the entry tooth, incrementing the walk depth by 1
for each tooth traversed.
3. On reaching the last tooth in the face, perform a cut by duplicating the
annular vertex at which the traversal closes (the annular vertex shared by
the last tooth and the closing tooth).
4. Find the tooth t of highest walk depth that is a member of a bite.
5. If t is incident to a face F with unlabelled teeth, traverse the teeth of F
starting from t in the direction of the unlabelled tooth incident to t
(sharing an annular vertex), incrementing the walk depth as you go.
6. Repeat steps 3-5 until all teeth are labelled.
7. Cut the apex of every up tooth, except entry teeth and except any apex
vertex that is shared by two up teeth.
The full medial tire graph model (annular cycle A(T), up/down teeth, bites, the
auxiliary plane graph B(T) and its inner faces) is the one from the companion
``full_medial_tire_generator.py`` of the medial tire decompositions paper, which
we import.
Teeth are identified with the annular edges that carry them: edge i sits on the
annular vertices a_i and a_{(i+1) mod n} and carries exactly one tooth. A bite
(i, j) carries two teeth, one on edge i and one on edge j, that share the bite
apex p. The inner non-tooth faces of B(T) are the root face (written ``None``)
and one inner-gap face per bite.
"""
from __future__ import annotations
import argparse
import math
import os
import sys
from collections import Counter
from collections.abc import Mapping
# Import the full medial tire model from the companion paper's lib directory.
_GEN_DIR = os.path.normpath(os.path.join(
os.path.dirname(__file__), "..", "..",
"medial_tire_decompositions_of_plane_triangulations", "lib",
))
sys.path.insert(0, _GEN_DIR)
from full_medial_tire_generator import ( # noqa: E402
FullMedialTireGraph,
has_incident_bite,
innermost_bite,
satisfies_bite_face_condition,
)
Face = "tuple[int, int] | None" # a bite (i, j), or None for the root face
# ---------------------------------------------------------------------------
# Face structure of B(T).
# ---------------------------------------------------------------------------
def parent_face(graph: FullMedialTireGraph, bite: tuple[int, int]) -> Face:
"""The face directly enclosing ``bite``: the minimal-span bite strictly
containing it, or the root face ``None``."""
i, j = bite
enclosing = [b for b in graph.bites if b[0] < i and b[1] > j]
if not enclosing:
return None
return min(enclosing, key=lambda b: b[1] - b[0])
def door_bite(graph: FullMedialTireGraph, edge: int) -> tuple[int, int] | None:
"""The bite that ``edge`` is a door of (i.e. a bite edge), or None."""
for b in graph.bites:
if edge in b:
return b
return None
def faces_bordered(graph: FullMedialTireGraph, edge: int) -> list[Face]:
"""The inner non-tooth faces whose boundary the tooth on ``edge`` lies on.
A bite door borders two faces (its bite's gap and that bite's parent); any
other tooth borders the single face directly containing its edge.
"""
bite = door_bite(graph, edge)
if bite is not None:
return [bite, parent_face(graph, bite)]
return [innermost_bite(edge, graph.bites)]
def face_boundary(graph: FullMedialTireGraph, face: Face) -> list[int]:
"""The teeth (annular edges) bounding ``face``, in clockwise cyclic order.
Clockwise is increasing edge index. For the root face the boundary is read
around the whole cycle; for a bite gap (i, j) it is read along the arc
i, i+1, ..., j and closes through the bite apex. Edges enclosed by a child
bite are skipped (they belong to the child's gap face).
"""
n = graph.n
arc = range(n) if face is None else range(face[0], face[1] + 1)
return [k for k in arc if face in faces_bordered(graph, k)]
def all_faces(graph: FullMedialTireGraph) -> list[Face]:
return [None] + sorted(graph.bites)
def shared_annular_vertex(graph: FullMedialTireGraph, e1: int, e2: int) -> int | None:
"""The annular vertex a_k shared by edges ``e1`` and ``e2``, or None."""
n = graph.n
common = {e1, (e1 + 1) % n} & {e2, (e2 + 1) % n}
return next(iter(common)) if common else None
# ---------------------------------------------------------------------------
# The walk-depth labelling and cut.
# ---------------------------------------------------------------------------
class Cut:
"""A cut performed when a face traversal closes: the duplicated annular
vertex, together with the last labelled tooth and the closing tooth that
share it, and the face being closed."""
__slots__ = ("vertex", "last_tooth", "closing_tooth", "face", "order")
def __init__(self, vertex, last_tooth, closing_tooth, face, order):
self.vertex = vertex
self.last_tooth = last_tooth
self.closing_tooth = closing_tooth
self.face = face
self.order = order
def __repr__(self):
f = "root" if self.face is None else f"bite{self.face}"
return (f"Cut(order={self.order}, a{self.vertex}, "
f"last=e{self.last_tooth}, closing=e{self.closing_tooth}, face={f})")
def label_and_cut(graph: FullMedialTireGraph, entry_edge: int,
start_depth: int = 0) -> tuple[dict[int, int], list[Cut]]:
"""Run the procedure starting from up tooth ``entry_edge``.
Returns ``(depth, cuts)`` where ``depth`` maps each annular edge (tooth) to
its walk depth, and ``cuts`` is the list of cuts in the order performed.
"""
if graph.tooth_word[entry_edge] != "U":
raise ValueError(f"entry edge {entry_edge} is not an up tooth")
depth: dict[int, int] = {}
cuts: list[Cut] = []
counter = start_depth
def traverse(face: Face, start_edge: int, is_entry: bool) -> None:
nonlocal counter
boundary = face_boundary(graph, face)
m = len(boundary)
pos = boundary.index(start_edge)
if is_entry:
depth[start_edge] = counter
counter += 1
direction = +1
else:
# head toward the unlabelled tooth incident to the door t
direction = +1 if boundary[(pos + 1) % m] not in depth else -1
last_new = start_edge
i = pos
while True:
i = (i + direction) % m
edge = boundary[i]
if edge in depth: # the closing tooth
cuts.append(Cut(
vertex=shared_annular_vertex(graph, last_new, edge),
last_tooth=last_new, closing_tooth=edge,
face=face, order=len(cuts),
))
return
depth[edge] = counter
counter += 1
last_new = edge
# Steps 1-3: the entry face.
traverse(innermost_bite(entry_edge, graph.bites), entry_edge, is_entry=True)
# Steps 4-6: descend (or ascend) through bites, deepest first. The root
# face is ``None``, so we use a distinct sentinel for "no unlabelled face".
_MISSING = object()
while len(depth) < graph.n:
labelled_bite_teeth = sorted(
(e for e in depth if door_bite(graph, e) is not None),
key=lambda e: depth[e], reverse=True,
)
for t in labelled_bite_teeth:
target = next((F for F in faces_bordered(graph, t)
if any(e not in depth for e in face_boundary(graph, F))),
_MISSING)
if target is not _MISSING:
traverse(target, t, is_entry=False)
break
else:
break # no progress possible
return depth, cuts
def up_apex_cuts(graph: FullMedialTireGraph, entry_edge: int,
bij: Mapping[str, object] | None = None) -> dict[int, object]:
"""Up-tooth apex cuts prescribed after the walk-depth traversal.
The returned dict maps each cut up-tooth edge to the apex vertex to
duplicate. Entry teeth are not cut. If ``bij`` is supplied, it maps the
model vertex names (``u{i}``) into the ambient medial graph; this lets a
real tread suppress cuts at a vertex that is the shared apex of two up
teeth. Without ``bij`` the model vertex names are used directly.
"""
apex_by_edge = {
i: (bij[f"u{i}"] if bij is not None else graph.apex_of_edge(i))
for i in graph.up_edges
}
multiplicity = Counter(apex_by_edge.values())
return {
i: apex
for i, apex in apex_by_edge.items()
if i != entry_edge and multiplicity[apex] == 1
}
# ---------------------------------------------------------------------------
# TikZ rendering.
# ---------------------------------------------------------------------------
def _coords(graph: FullMedialTireGraph,
r_ann=1.0, r_up=1.46, r_down=0.60) -> dict[str, tuple[float, float]]:
n = graph.n
def ang(k): # a_0 at the top, increasing k clockwise
return math.radians(90.0 - k * 360.0 / n)
def edge_mid_dir(i): # angle of the bisector of edge i's two endpoints
a0, a1 = ang(i), ang((i + 1) % n)
return math.atan2(math.sin(a0) + math.sin(a1), math.cos(a0) + math.cos(a1))
pos = {f"a{k}": (r_ann * math.cos(ang(k)), r_ann * math.sin(ang(k)))
for k in range(n)}
for i in graph.up_edges:
a = edge_mid_dir(i)
pos[f"u{i}"] = (r_up * math.cos(a), r_up * math.sin(a))
for i in graph.singleton_down_edges:
a = edge_mid_dir(i)
pos[f"d{i}"] = (r_down * math.cos(a), r_down * math.sin(a))
for (i, j) in graph.bites:
pts = [pos[f"a{i}"], pos[f"a{(i + 1) % n}"],
pos[f"a{j}"], pos[f"a{(j + 1) % n}"]]
cx = sum(p[0] for p in pts) / 4.0
cy = sum(p[1] for p in pts) / 4.0
pos[f"p{i}_{j}"] = (0.9 * cx, 0.9 * cy)
return pos
def _edge_midpoint(pos, graph, edge):
n = graph.n
a, b = pos[f"a{edge}"], pos[f"a{(edge + 1) % n}"]
return (0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1]))
def to_tikz(graph: FullMedialTireGraph,
depth: dict[int, int] | None = None,
cuts: list[Cut] | None = None,
entry_edge: int | None = None,
scale: float = 2.2) -> str:
"""A standalone ``tikzpicture`` for ``graph``; if ``depth`` is given, draw
the walk-depth labels and (with ``cuts``) the cut marks."""
pos = _coords(graph)
n = graph.n
L = []
A = L.append
A(f"\\begin{{tikzpicture}}[scale={scale},")
A(" ann/.style={circle, fill=black, inner sep=1.0pt},")
A(" upv/.style={circle, draw=blue!70!black, fill=blue!12, inner sep=1.4pt},")
A(" downv/.style={circle, draw=red!70!black, fill=red!12, inner sep=1.4pt},")
A(" bitev/.style={circle, draw=red!70!black, fill=red!32, inner sep=1.7pt},")
A(" cyc/.style={black, line width=1.0pt},")
A(" tth/.style={black!55, line width=0.4pt},")
A(" lbl/.style={font=\\scriptsize},")
A(" dlbl/.style={font=\\scriptsize\\bfseries, text=black},")
A(" cut/.style={red!80!black, line width=1.3pt},")
A(" cutlbl/.style={font=\\tiny, text=red!75!black}]")
def pt(name):
x, y = pos[name]
return f"({x:.3f},{y:.3f})"
# annular cycle
cyc = "--".join(pt(f"a{k}") for k in range(n)) + "--cycle"
A(f"\\draw[cyc] {cyc};")
# spokes
for i in graph.up_edges:
A(f"\\draw[tth] {pt(f'u{i}')}--{pt(f'a{i}')} {pt(f'u{i}')}--{pt(f'a{(i+1)%n}')};")
for i in graph.singleton_down_edges:
A(f"\\draw[tth] {pt(f'd{i}')}--{pt(f'a{i}')} {pt(f'd{i}')}--{pt(f'a{(i+1)%n}')};")
for (i, j) in graph.bites:
apex = f"p{i}_{j}"
for e in (i, j):
A(f"\\draw[tth] {pt(apex)}--{pt(f'a{e}')} {pt(apex)}--{pt(f'a{(e+1)%n}')};")
# vertices
for k in range(n):
A(f"\\node[ann] at {pt(f'a{k}')} {{}};")
for i in graph.up_edges:
A(f"\\node[upv] at {pt(f'u{i}')} {{}};")
for i in graph.singleton_down_edges:
A(f"\\node[downv] at {pt(f'd{i}')} {{}};")
for (i, j) in sorted(graph.bites):
A(f"\\node[bitev] at {pt(f'p{i}_{j}')} {{}};")
# walk-depth labels: placed along the spoke from apex toward the edge mid
if depth is not None:
for edge in range(n):
apex = graph.apex_of_edge(edge)
ax, ay = pos[apex]
mx, my = _edge_midpoint(pos, graph, edge)
f = 0.5
lx, ly = ax + f * (mx - ax), ay + f * (my - ay)
A(f"\\node[dlbl] at ({lx:.3f},{ly:.3f}) {{{depth[edge]}}};")
# cut marks: a short red slit across the duplicated annular vertex
if cuts:
for c in cuts:
if c.vertex is None:
continue
vx, vy = pos[f"a{c.vertex}"]
rad = math.atan2(vy, vx)
dx, dy = 0.16 * math.cos(rad), 0.16 * math.sin(rad)
A(f"\\draw[cut] ({vx-dx:.3f},{vy-dy:.3f})--({vx+dx:.3f},{vy+dy:.3f});")
lx, ly = vx + 0.30 * math.cos(rad), vy + 0.30 * math.sin(rad)
A(f"\\node[cutlbl] at ({lx:.3f},{ly:.3f}) {{cut {c.order+1}}};")
# up-tooth apex cuts: tangential slits, excluding the entry tooth and any
# up apex shared by two up teeth.
if entry_edge is not None:
for i in up_apex_cuts(graph, entry_edge):
vx, vy = pos[f"u{i}"]
rad = math.atan2(vy, vx)
tx, ty = -math.sin(rad), math.cos(rad)
A(f"\\draw[cut] ({vx-0.12*tx:.3f},{vy-0.12*ty:.3f})--"
f"({vx+0.12*tx:.3f},{vy+0.12*ty:.3f});")
if entry_edge is not None:
ex, ey = pos[graph.apex_of_edge(entry_edge)]
rad = math.atan2(ey, ex)
tx, ty = ex + 0.34 * math.cos(rad), ey + 0.34 * math.sin(rad)
A(f"\\node[lbl, text=blue!60!black] at ({tx:.3f},{ty:.3f}) {{entry}};")
A("\\end{tikzpicture}")
return "\n".join(L)
# ---------------------------------------------------------------------------
# Worked example and CLI.
# ---------------------------------------------------------------------------
def worked_example() -> FullMedialTireGraph:
"""A clean 8-tooth piece: one bite (0,4), three down singletons 1,2,3 in its
gap, three up teeth 5,6,7 in the root face."""
return FullMedialTireGraph(n=8, tooth_word="DDDDDUUU", bites=frozenset({(0, 4)}))
def _check(graph: FullMedialTireGraph) -> None:
assert not has_incident_bite(graph.bites, graph.n), "bite uses incident edges"
assert satisfies_bite_face_condition(graph.tooth_word, graph.bites), \
"violates the bite-face condition"
assert graph.tooth_word.count("U") >= 3, "fewer than three up teeth"
def _describe(graph, depth, cuts, entry_edge) -> str:
lines = ["edge type walk-depth"]
for e in range(graph.n):
t = graph.tooth_word[e]
kind = {"U": "up"}.get(t, "down")
if door_bite(graph, e) is not None:
kind = "bite"
lines.append(f" e{e} {kind:<5} {depth[e]}")
lines.append("cuts (in order):")
for c in cuts:
f = "root" if c.face is None else f"bite{c.face}"
lines.append(f" cut {c.order+1}: duplicate a{c.vertex} "
f"(closing tooth e{c.closing_tooth} of {f})")
apex_cuts = up_apex_cuts(graph, entry_edge)
if apex_cuts:
lines.append("up-apex cuts:")
for edge, apex in apex_cuts.items():
lines.append(f" duplicate {apex} for up tooth e{edge}")
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--entry", default="u5",
help="entry up tooth, as an edge index or apex name like u5")
parser.add_argument("--start-depth", type=int, default=0)
parser.add_argument("--tikz", choices=["plain", "labelled", "both"],
help="emit TikZ for the worked example")
args = parser.parse_args()
entry = args.entry
edge = int(entry[1:]) if isinstance(entry, str) and entry.startswith("u") else int(entry)
graph = worked_example()
_check(graph)
depth, cuts = label_and_cut(graph, edge, start_depth=args.start_depth)
if args.tikz == "plain":
print(to_tikz(graph))
elif args.tikz == "labelled":
print(to_tikz(graph, depth=depth, cuts=cuts, entry_edge=edge))
elif args.tikz == "both":
print("% --- plain ---")
print(to_tikz(graph))
print("% --- labelled + cut ---")
print(to_tikz(graph, depth=depth, cuts=cuts, entry_edge=edge))
else:
print(f"worked example: n={graph.n} word={graph.tooth_word} "
f"bites={sorted(graph.bites)} entry=e{edge}")
print(_describe(graph, depth, cuts, edge))
if __name__ == "__main__":
main()
+15 -3
View File
@@ -112,9 +112,21 @@ cuts as follows.
$1$ as you travel. (Here a tooth is \emph{incident to $t$} when it
shares an annular vertex of $A(T)$ with $t$.)
\item Repeat steps (3)--(5) until all teeth have been labelled.
\item Finally, perform an apex cut at every up tooth except an entry
tooth. If the same medial vertex is the apex of two up teeth, do not
cut that shared apex vertex.
\end{enumerate}
\end{definition}
\begin{remark}[Entry and shared up-apex exceptions]
\label{rem:up-apex-cut-exceptions}
For a single full medial tire graph there is one entry tooth. In a
chained tire decomposition each tread has its own entry tooth, inherited
from the parent side or chosen at the root. These entry triangles are
left uncut. Shared up-apex vertices are also left uncut: the intended
cut set contains the apexes of singleton up teeth only.
\end{remark}
\begin{remark}[Closing tooth of a descended face]
\label{rem:closing-tooth}
For the entry face the traversal of step (2) closes by returning to the
@@ -156,9 +168,9 @@ three down teeth
and closes on the already-labelled bite tooth of edge $0$, so step (3)
cuts by duplicating the annular vertex $a_1$
(Remark~\ref{rem:closing-tooth}). All eight teeth are now labelled, and
the two cuts leave only the outer face and the eight teeth as
$3$-faces. The labelling and cuts are produced by the script
\texttt{experiments/medial\_tire\_cut\_labelling.py}.
the closing cuts are followed by apex cuts at the non-entry up teeth on
edges $6$ and $7$. The labelling and cuts are produced by the script
\texttt{lib/medial\_tire\_cut\_labelling.py}.
\end{example}
\begin{figure}[h]