Files
math-research/papers/medial_tire_cuts/experiments/medial_tire_cut_labelling.py
T
didericis a22ca4b888 Add medial tire cut experiment and chaining section
New experiments/run_medial_tire_cut_experiment.py: generates a random
maximal planar graph (stacked seed + random diagonal flips), builds the
medial graph, takes the tire decomposition at a random vertex level
source, walk-depth labels and cuts each full medial tire graph chained
down the tire tree, and assembles one final cut graph of M(G) with a
global label map (data only; graphics go in a separate script).

Fix label_and_cut: the root face is None, which collided with the
next(..., None) sentinel, leaving teeth unlabelled when the entry up
tooth lay inside a bite gap; use a distinct sentinel so the ascent to
the root face runs.

Add a "Chaining across the tire tree" section to the paper, clarifying
that the candidate parent down teeth are the boundary (singleton) down
teeth only -- bite teeth are interior to the parent and shared with no
child, so a lower-walk bite is skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:46:49 -04:00

389 lines
15 KiB
Python

"""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.
"""
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)
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))
if __name__ == "__main__":
main()