Update medial tire cut labelling
This commit is contained in:
@@ -29,7 +29,10 @@ import networkx as nx
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
_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, _HERE)
|
||||||
|
sys.path.insert(0, _CUT_LIB)
|
||||||
|
|
||||||
from run_medial_tire_cut_experiment import run_experiment # noqa: E402
|
from run_medial_tire_cut_experiment import run_experiment # noqa: E402
|
||||||
from medial_tire_cut_labelling import to_tikz # 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.
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.
|
"""Compatibility wrapper for the medial tire cut labelling script."""
|
||||||
|
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
|
||||||
import math
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Import the full medial tire model from the companion paper's experiments.
|
PAPER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
_GEN_DIR = os.path.normpath(os.path.join(
|
LIB_DIR = os.path.join(PAPER_DIR, "lib")
|
||||||
os.path.dirname(__file__), "..", "..",
|
if LIB_DIR not in sys.path:
|
||||||
"medial_tire_decompositions_of_plane_triangulations", "experiments",
|
sys.path.insert(0, LIB_DIR)
|
||||||
))
|
|
||||||
sys.path.insert(0, _GEN_DIR)
|
|
||||||
|
|
||||||
from full_medial_tire_generator import ( # noqa: E402
|
from medial_tire_cut_labelling import main
|
||||||
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -50,8 +50,11 @@ _HERE = os.path.dirname(os.path.abspath(__file__))
|
|||||||
_MTD = os.path.normpath(os.path.join(
|
_MTD = os.path.normpath(os.path.join(
|
||||||
_HERE, "..", "..",
|
_HERE, "..", "..",
|
||||||
"medial_tire_decompositions_of_plane_triangulations", "experiments"))
|
"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, _HERE)
|
||||||
|
sys.path.insert(0, _MTD)
|
||||||
|
sys.path.insert(0, _CUT_LIB)
|
||||||
|
|
||||||
from tire_realization_analysis import ( # noqa: E402
|
from tire_realization_analysis import ( # noqa: E402
|
||||||
ekey, extract_tread, medial_graph, medial_tire_facemodel,
|
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,
|
_assemble_cut_graph, _cap_cut, _label_treads,
|
||||||
random_maximal_planar_min_degree,
|
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):
|
def up_apex_cut_edges(results):
|
||||||
"""Primal edges whose dual edge the apex duplications remove: the apex
|
"""Primal edges whose dual edge the apex duplications remove: the apex
|
||||||
medial vertex of every (singleton) up tooth across all treads, except the
|
medial vertex of every up tooth across all treads, except each tread's
|
||||||
entry tooth of each tread (its apex is not duplicated)."""
|
entry tooth and any vertex that is the shared apex of two up teeth."""
|
||||||
removed = set()
|
removed = set()
|
||||||
for key in sorted(results):
|
for key in sorted(results):
|
||||||
g, bij = results[key]["g"], results[key]["bij"]
|
g, bij = results[key]["g"], results[key]["bij"]
|
||||||
entry = results[key]["entry_edge"]
|
entry = results[key]["entry_edge"]
|
||||||
for i in g.up_edges:
|
removed.update(up_apex_cuts(g, entry, bij=bij).values())
|
||||||
if i == entry:
|
|
||||||
continue
|
|
||||||
removed.add(bij[f"u{i}"])
|
|
||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|
||||||
@@ -412,16 +413,8 @@ def _radial_source_layout(G, source, levels):
|
|||||||
return pos
|
return pos
|
||||||
|
|
||||||
|
|
||||||
def draw_png(result, path, scale=6.0):
|
def _draw_dual_cut_ax(ax, result):
|
||||||
"""Render the source-dual cut: dual nodes at face centroids, dual edges
|
"""Draw the source-dual cut on an existing Matplotlib axis."""
|
||||||
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
|
|
||||||
|
|
||||||
G, faces, dual = result["G"], result["faces"], result["dual"]
|
G, faces, dual = result["G"], result["faces"], result["dual"]
|
||||||
removed = result["removed_dual_edges"]
|
removed = result["removed_dual_edges"]
|
||||||
missing = result["dual_face_missing"]
|
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)
|
return (sum(xs) / 3.0, sum(ys) / 3.0)
|
||||||
|
|
||||||
pos = {fi: centroid(fi) for fi in dual.nodes()}
|
pos = {fi: centroid(fi) for fi in dual.nodes()}
|
||||||
fig, ax = plt.subplots(figsize=(7.6, 7.6))
|
|
||||||
# primal (source) graph, faint, for orientation
|
# primal (source) graph, faint, for orientation
|
||||||
for u, v in G.edges():
|
for u, v in G.edges():
|
||||||
ax.plot([pos_v[u][0], pos_v[v][0]], [pos_v[u][1], pos_v[v][1]],
|
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)
|
f"max {result['max_missing']}", fontsize=9)
|
||||||
ax.set_aspect("equal")
|
ax.set_aspect("equal")
|
||||||
ax.axis("off")
|
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.tight_layout()
|
||||||
fig.savefig(path, dpi=150)
|
fig.savefig(path, dpi=150)
|
||||||
plt.close(fig)
|
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",
|
f"cut {c.order + 1}", fontsize=6, color="#cc2020",
|
||||||
ha="center", va="center", zorder=5)
|
ha="center", va="center", zorder=5)
|
||||||
# up-tooth apex duplications (slit tangential, across the apex marker);
|
# up-tooth apex duplications (slit tangential, across the apex marker);
|
||||||
# the entry tooth's apex is not duplicated
|
# entry and shared up-apex vertices are not duplicated.
|
||||||
for i in g.up_edges:
|
for i in up_apex_cuts(g, entry_edge):
|
||||||
if i == entry_edge:
|
|
||||||
continue
|
|
||||||
vx, vy = pos[f"u{i}"]
|
vx, vy = pos[f"u{i}"]
|
||||||
rad = math.atan2(vy, vx)
|
rad = math.atan2(vy, vx)
|
||||||
tx, ty = -math.sin(rad), math.cos(rad) # tangential
|
tx, ty = -math.sin(rad), math.cos(rad) # tangential
|
||||||
@@ -635,6 +639,116 @@ def draw_tire_cuts_png(result, path):
|
|||||||
plt.close(fig)
|
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):
|
def draw_cap_png(result, path):
|
||||||
"""Render tread 0, the source cap: a wheel with the source at the hub, its
|
"""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
|
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")
|
help="render each full medial tire cut to PNG")
|
||||||
parser.add_argument("--cap-png", metavar="PATH",
|
parser.add_argument("--cap-png", metavar="PATH",
|
||||||
help="render tread 0 (the source cap) to PNG")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
rng = random.Random(args.seed)
|
rng = random.Random(args.seed)
|
||||||
@@ -759,6 +875,9 @@ def main():
|
|||||||
if args.cap_png:
|
if args.cap_png:
|
||||||
draw_cap_png(result, args.cap_png)
|
draw_cap_png(result, args.cap_png)
|
||||||
print(f"wrote {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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -49,8 +49,11 @@ _HERE = os.path.dirname(os.path.abspath(__file__))
|
|||||||
_MTD = os.path.normpath(os.path.join(
|
_MTD = os.path.normpath(os.path.join(
|
||||||
_HERE, "..", "..",
|
_HERE, "..", "..",
|
||||||
"medial_tire_decompositions_of_plane_triangulations", "experiments"))
|
"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, _HERE)
|
||||||
|
sys.path.insert(0, _MTD)
|
||||||
|
sys.path.insert(0, _CUT_LIB)
|
||||||
|
|
||||||
from tire_realization_analysis import ( # noqa: E402
|
from tire_realization_analysis import ( # noqa: E402
|
||||||
ekey, extract_tread, medial_graph, medial_tire_facemodel,
|
ekey, extract_tread, medial_graph, medial_tire_facemodel,
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -112,9 +112,21 @@ cuts as follows.
|
|||||||
$1$ as you travel. (Here a tooth is \emph{incident to $t$} when it
|
$1$ as you travel. (Here a tooth is \emph{incident to $t$} when it
|
||||||
shares an annular vertex of $A(T)$ with $t$.)
|
shares an annular vertex of $A(T)$ with $t$.)
|
||||||
\item Repeat steps (3)--(5) until all teeth have been labelled.
|
\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{enumerate}
|
||||||
\end{definition}
|
\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]
|
\begin{remark}[Closing tooth of a descended face]
|
||||||
\label{rem:closing-tooth}
|
\label{rem:closing-tooth}
|
||||||
For the entry face the traversal of step (2) closes by returning to the
|
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)
|
and closes on the already-labelled bite tooth of edge $0$, so step (3)
|
||||||
cuts by duplicating the annular vertex $a_1$
|
cuts by duplicating the annular vertex $a_1$
|
||||||
(Remark~\ref{rem:closing-tooth}). All eight teeth are now labelled, and
|
(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
|
the closing cuts are followed by apex cuts at the non-entry up teeth on
|
||||||
$3$-faces. The labelling and cuts are produced by the script
|
edges $6$ and $7$. The labelling and cuts are produced by the script
|
||||||
\texttt{experiments/medial\_tire\_cut\_labelling.py}.
|
\texttt{lib/medial\_tire\_cut\_labelling.py}.
|
||||||
\end{example}
|
\end{example}
|
||||||
|
|
||||||
\begin{figure}[h]
|
\begin{figure}[h]
|
||||||
|
|||||||
Reference in New Issue
Block a user