"""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()