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