Add full medial tire graph generator and n=9 atlas
Name A(T) the "annular cycle" (Thm 3.3, Def 3.4); clarify the bite-face condition in Remark 3.8 to count down-tooth apexes interior to each face; add the non-incidence stipulation for bite edges to Def 3.7. Add an exhaustive generator over |A(T)| enforcing the 3.1-3.9 properties (tooth word, non-crossing non-incident bites, >=3 up teeth, bite-face condition), plus a plotting script and the n=9 atlas (81 dihedral classes). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+312
@@ -0,0 +1,312 @@
|
||||
"""Exhaustive generator for full medial tire graphs, indexed by |A(T)|.
|
||||
|
||||
Model (Definitions/Remarks 3.1--3.9 of the medial tire decompositions paper).
|
||||
|
||||
* The annular medial vertices induce a cycle A(T), the *annular cycle*
|
||||
(Theorem 3.3). Write n = |A(T)| for its number of vertices = number of
|
||||
annular faces = number of annular edges e_0,...,e_{n-1}.
|
||||
|
||||
* Each edge e_i of A(T) carries exactly one tooth (a triangle of M(T))
|
||||
whose third vertex is a non-annular apex (Definition 3.4). A tooth is an
|
||||
*up tooth* (apex in the outer region) or a *down tooth* (apex in the inner
|
||||
region). We record the tooth types as a word in {U, D}^n.
|
||||
|
||||
* No two up teeth share an apex; at most two down teeth share an apex
|
||||
(Remark 3.5). Two down teeth sharing an apex form a *bite* (Definition
|
||||
3.7). So the down teeth are partitioned into singletons and bite pairs.
|
||||
A bite pairs two down-edges and is drawn as an apex inside the disk with
|
||||
spokes to the four endpoints; bites must be mutually non-crossing, i.e.
|
||||
the bite pairs form a non-crossing (laminar) matching of the down-edges.
|
||||
The two annular edges of a bite must be non-incident (Definition 3.7):
|
||||
they share no annular vertex, so cyclically adjacent edges cannot pair.
|
||||
|
||||
* There are at least three up teeth (Remark 3.6).
|
||||
|
||||
* Bite-face condition (Remark 3.8). Let B(T) = A(T) together with the bite
|
||||
apexes. Its interior non-tooth faces are the root face plus one inner-gap
|
||||
face per bite. A singleton down tooth lies in the innermost bite enclosing
|
||||
its edge (or in the root face if none). For every interior non-tooth face
|
||||
the number of down-tooth apexes lying in that face must be 0 or at least 3.
|
||||
Equivalently: no face holds exactly one or two singleton down teeth.
|
||||
|
||||
The generator enumerates, for a given n, every (tooth word, bite matching)
|
||||
pair satisfying these properties and emits the resulting full medial tire
|
||||
graph as an explicit vertex/edge structure. Configurations may optionally be
|
||||
reduced modulo the dihedral symmetry of the cycle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Iterator
|
||||
|
||||
# A bite is an unordered pair of down-edge indices (i, j) with i < j.
|
||||
Bite = tuple[int, int]
|
||||
Matching = frozenset[Bite]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-crossing (laminar) matchings of the down edges.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def noncrossing_matchings(positions: tuple[int, ...]) -> tuple[Matching, ...]:
|
||||
"""All non-crossing partial matchings of ``positions`` (sorted ascending).
|
||||
|
||||
Bite pairs drawn inside the disk are non-crossing iff, read in cyclic
|
||||
order, no two pairs interleave. Cutting the cycle at the gap before the
|
||||
first edge turns this into ordinary non-crossing interval matchings, which
|
||||
obey the Catalan recursion below.
|
||||
"""
|
||||
if not positions:
|
||||
return (frozenset(),)
|
||||
head, *rest = positions
|
||||
out: list[Matching] = []
|
||||
# head left unmatched (a singleton down tooth, if its edge is down)
|
||||
for tail in noncrossing_matchings(tuple(rest)):
|
||||
out.append(tail)
|
||||
# head matched with positions[k]; the strictly-enclosed block must be
|
||||
# matched within itself to stay non-crossing.
|
||||
for k in range(1, len(positions)):
|
||||
partner = positions[k]
|
||||
inside = tuple(positions[1:k])
|
||||
outside = tuple(positions[k + 1:])
|
||||
for m_in in noncrossing_matchings(inside):
|
||||
for m_out in noncrossing_matchings(outside):
|
||||
out.append(frozenset({(head, partner)}) | m_in | m_out)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The bite-face condition (Remark 3.8).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def incident_edges(i: int, j: int, n: int) -> bool:
|
||||
"""Whether annular edges i and j share an annular vertex on the n-cycle."""
|
||||
return (j - i) % n == 1 or (i - j) % n == 1
|
||||
|
||||
|
||||
def has_incident_bite(bites: Matching, n: int) -> bool:
|
||||
"""Whether any bite pairs two incident (cyclically adjacent) edges."""
|
||||
return any(incident_edges(i, j, n) for i, j in bites)
|
||||
|
||||
|
||||
def innermost_bite(edge: int, bites: Matching) -> Bite | None:
|
||||
"""The minimal-span bite whose open interval contains ``edge``, or None."""
|
||||
enclosing = [b for b in bites if b[0] < edge < b[1]]
|
||||
if not enclosing:
|
||||
return None
|
||||
return min(enclosing, key=lambda b: b[1] - b[0])
|
||||
|
||||
|
||||
def face_singleton_counts(
|
||||
tooth_word: str, bites: Matching
|
||||
) -> dict[Bite | None, int]:
|
||||
"""Down-singletons per interior non-tooth face of B(T).
|
||||
|
||||
The key ``None`` is the root face; a bite key is that bite's inner-gap
|
||||
face. Faces with no singletons are simply absent from the result.
|
||||
"""
|
||||
matched = {edge for pair in bites for edge in pair}
|
||||
counts: dict[Bite | None, int] = defaultdict(int)
|
||||
for edge, tooth in enumerate(tooth_word):
|
||||
if tooth != "D" or edge in matched:
|
||||
continue # only singleton down teeth contribute apexes
|
||||
counts[innermost_bite(edge, bites)] += 1
|
||||
return dict(counts)
|
||||
|
||||
|
||||
def satisfies_bite_face_condition(tooth_word: str, bites: Matching) -> bool:
|
||||
"""Remark 3.8: every non-tooth face holds 0 or >=3 down-tooth apexes."""
|
||||
return all(count >= 3 for count in face_singleton_counts(tooth_word, bites).values())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The full medial tire graph as an explicit object.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FullMedialTireGraph:
|
||||
"""A full medial tire graph M(T) determined by its combinatorial data.
|
||||
|
||||
Vertices are named:
|
||||
a{k} annular medial vertex k (k = 0..n-1), forming A(T);
|
||||
u{i} apex of the up tooth on edge i;
|
||||
d{i} apex of the singleton down tooth on edge i;
|
||||
p{i}_{j} apex of the bite pairing edges i and j (i < j).
|
||||
"""
|
||||
|
||||
n: int
|
||||
tooth_word: str
|
||||
bites: Matching
|
||||
|
||||
@property
|
||||
def up_edges(self) -> tuple[int, ...]:
|
||||
return tuple(i for i, t in enumerate(self.tooth_word) if t == "U")
|
||||
|
||||
@property
|
||||
def down_edges(self) -> tuple[int, ...]:
|
||||
return tuple(i for i, t in enumerate(self.tooth_word) if t == "D")
|
||||
|
||||
@property
|
||||
def bite_edges(self) -> frozenset[int]:
|
||||
return frozenset(edge for pair in self.bites for edge in pair)
|
||||
|
||||
@property
|
||||
def singleton_down_edges(self) -> tuple[int, ...]:
|
||||
bite = self.bite_edges
|
||||
return tuple(i for i in self.down_edges if i not in bite)
|
||||
|
||||
def apex_of_edge(self, edge: int) -> str:
|
||||
if self.tooth_word[edge] == "U":
|
||||
return f"u{edge}"
|
||||
for i, j in self.bites:
|
||||
if edge in (i, j):
|
||||
return f"p{i}_{j}"
|
||||
return f"d{edge}"
|
||||
|
||||
def vertices(self) -> list[str]:
|
||||
verts = [f"a{k}" for k in range(self.n)]
|
||||
for i in self.up_edges:
|
||||
verts.append(f"u{i}")
|
||||
for i in self.singleton_down_edges:
|
||||
verts.append(f"d{i}")
|
||||
for i, j in sorted(self.bites):
|
||||
verts.append(f"p{i}_{j}")
|
||||
return verts
|
||||
|
||||
def edges(self) -> list[tuple[str, str]]:
|
||||
n = self.n
|
||||
out: list[tuple[str, str]] = []
|
||||
# annular cycle A(T)
|
||||
for k in range(n):
|
||||
out.append((f"a{k}", f"a{(k + 1) % n}"))
|
||||
# singleton teeth (up and down): two spokes each
|
||||
for i in self.up_edges:
|
||||
out += [(f"u{i}", f"a{i}"), (f"u{i}", f"a{(i + 1) % n}")]
|
||||
for i in self.singleton_down_edges:
|
||||
out += [(f"d{i}", f"a{i}"), (f"d{i}", f"a{(i + 1) % n}")]
|
||||
# bites: a shared apex with four spokes
|
||||
for i, j in sorted(self.bites):
|
||||
apex = f"p{i}_{j}"
|
||||
for edge in (i, j):
|
||||
out += [(apex, f"a{edge}"), (apex, f"a{(edge + 1) % n}")]
|
||||
return [tuple(sorted(e)) for e in out]
|
||||
|
||||
def canonical_key(self) -> tuple:
|
||||
"""Representative under the dihedral group of the cycle (rotations and
|
||||
reflections), so symmetric configurations collapse to one key."""
|
||||
n = self.n
|
||||
best: tuple | None = None
|
||||
for a in (1, -1):
|
||||
for b in range(n):
|
||||
relabel = lambda i: (a * i + b) % n
|
||||
word = [""] * n
|
||||
for i, t in enumerate(self.tooth_word):
|
||||
word[relabel(i)] = t
|
||||
mapped = tuple(sorted(
|
||||
tuple(sorted((relabel(i), relabel(j)))) for i, j in self.bites
|
||||
))
|
||||
key = (tuple(word), mapped)
|
||||
if best is None or key < best:
|
||||
best = key
|
||||
return best
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enumeration.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate(
|
||||
n: int, min_up_teeth: int = 3, dedup: bool = False
|
||||
) -> Iterator[FullMedialTireGraph]:
|
||||
"""Yield every full medial tire graph whose annular cycle has size ``n``.
|
||||
|
||||
``min_up_teeth`` defaults to 3 (Remark 3.6). With ``dedup`` set, only one
|
||||
representative per dihedral symmetry class is returned.
|
||||
"""
|
||||
seen: set[tuple] = set()
|
||||
for word_tuple in itertools.product("UD", repeat=n):
|
||||
tooth_word = "".join(word_tuple)
|
||||
if tooth_word.count("U") < min_up_teeth:
|
||||
continue
|
||||
down = tuple(i for i, t in enumerate(tooth_word) if t == "D")
|
||||
for bites in noncrossing_matchings(down):
|
||||
if has_incident_bite(bites, n):
|
||||
continue
|
||||
if not satisfies_bite_face_condition(tooth_word, bites):
|
||||
continue
|
||||
graph = FullMedialTireGraph(n=n, tooth_word=tooth_word, bites=bites)
|
||||
if dedup:
|
||||
key = graph.canonical_key()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
yield graph
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def figure_one() -> FullMedialTireGraph:
|
||||
"""The example graph of Figure 1 (Remark 3.8): 12 edges, one bite (0,6)."""
|
||||
return FullMedialTireGraph(
|
||||
n=12,
|
||||
tooth_word="DDDDDUDUUUUU", # edges 0-4,6 down; 5,7,8,9,10,11 up
|
||||
bites=frozenset({(0, 6)}),
|
||||
)
|
||||
|
||||
|
||||
def describe(graph: FullMedialTireGraph) -> str:
|
||||
counts = face_singleton_counts(graph.tooth_word, graph.bites)
|
||||
face_strs = []
|
||||
for face, c in sorted(counts.items(), key=lambda kv: (kv[0] is not None, kv[0])):
|
||||
name = "root" if face is None else f"bite{face}"
|
||||
face_strs.append(f"{name}:{c}")
|
||||
bites = ",".join(f"({i},{j})" for i, j in sorted(graph.bites)) or "-"
|
||||
faces = " ".join(face_strs) or "-"
|
||||
return (
|
||||
f"word={graph.tooth_word} up={len(graph.up_edges)} "
|
||||
f"down={len(graph.down_edges)} bites={bites} faces[{faces}]"
|
||||
)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
if args.check_figure:
|
||||
g = figure_one()
|
||||
print("Figure 1 check:")
|
||||
print(f" {describe(g)}")
|
||||
ok = satisfies_bite_face_condition(g.tooth_word, g.bites)
|
||||
print(f" satisfies Remark 3.8: {ok} (expect True; faces 4 and 0)")
|
||||
print()
|
||||
|
||||
for n in range(args.min_n, args.max_n + 1):
|
||||
graphs = list(generate(n, min_up_teeth=args.min_up, dedup=args.dedup))
|
||||
label = "classes" if args.dedup else "graphs"
|
||||
print(f"n={n}: {len(graphs)} {label}")
|
||||
if args.show:
|
||||
for g in graphs[: args.show]:
|
||||
print(f" {describe(g)}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--min-n", type=int, default=3)
|
||||
parser.add_argument("--max-n", type=int, default=8)
|
||||
parser.add_argument("--min-up", type=int, default=3, help="Remark 3.6 bound")
|
||||
parser.add_argument("--dedup", action="store_true",
|
||||
help="reduce modulo dihedral symmetry of the cycle")
|
||||
parser.add_argument("--show", type=int, default=0,
|
||||
help="print up to this many graphs per n")
|
||||
parser.add_argument("--check-figure", action="store_true",
|
||||
help="verify the Figure 1 example against Remark 3.8")
|
||||
run(parser.parse_args())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
|
After Width: | Height: | Size: 469 KiB |
+69
@@ -0,0 +1,69 @@
|
||||
# Atlas of full medial tire graphs with |A(T)| = 9
|
||||
|
||||
This note collects every full medial tire graph whose annular cycle `A(T)` has
|
||||
nine vertices, generated exhaustively from the structural properties in
|
||||
Definitions/Remarks 3.1–3.9 of `paper.tex`.
|
||||
|
||||
## What is being enumerated
|
||||
|
||||
A full medial tire graph of size `n = |A(T)|` is determined by:
|
||||
|
||||
- a tooth word in `{U, D}^n` — one up (`U`) or down (`D`) tooth per annular
|
||||
edge (Def. 3.4), with **at least three up teeth** (Rem. 3.6);
|
||||
- a **non-crossing matching** of the down edges into *bites* — pairs of down
|
||||
teeth sharing an apex (Rem. 3.5, Def. 3.7); unmatched down teeth are
|
||||
singletons. The two annular edges of a bite must be **non-incident**
|
||||
(Def. 3.7): they share no annular vertex, so cyclically adjacent edges
|
||||
cannot pair;
|
||||
- subject to the **bite-face condition** (Rem. 3.8): in `B(T) = A(T) + bite
|
||||
apexes`, every interior non-tooth face must contain `0` or `≥ 3`
|
||||
down-tooth apexes in its interior (equivalently, no face holds exactly one
|
||||
or two singleton down teeth).
|
||||
|
||||
Graphs are identified up to the dihedral symmetry of the annular cycle
|
||||
(rotations and reflections), since these give isomorphic plane graphs.
|
||||
|
||||
## The atlas
|
||||
|
||||

|
||||
|
||||
High-resolution vector copy: [`full_medial_tire_n9.pdf`](full_medial_tire_n9.pdf).
|
||||
Full textual index: [`full_medial_tire_n9_index.txt`](full_medial_tire_n9_index.txt).
|
||||
|
||||
In each diagram the thick black ring is `A(T)`; **blue** outer apexes are up
|
||||
teeth, **red** inner apexes are singleton down teeth, and a **dark-red** inner
|
||||
apex with four spokes is a bite (its two paired annular edges). The label under
|
||||
each diagram is the tooth word and the bite pairs (edge indices).
|
||||
|
||||
## Counts
|
||||
|
||||
There are **81** classes for `n = 9` (cf. `3:1, 4:1, 5:2, 6:6, 7:13, 8:36,
|
||||
9:81` for `n = 3..9`, with the non-incidence stipulation in force). Breakdown
|
||||
of the 81 classes:
|
||||
|
||||
| down teeth | classes | | bites | classes | | up teeth | classes |
|
||||
|-----------:|--------:|---|------:|--------:|---|---------:|--------:|
|
||||
| 0 | 1 | | 0 | 35 | | 3 | 23 |
|
||||
| 2 | 3 | | 1 | 35 | | 4 | 29 |
|
||||
| 3 | 7 | | 2 | 8 | | 5 | 18 |
|
||||
| 4 | 18 | | 3 | 3 | | 6 | 7 |
|
||||
| 5 | 29 | | | | | 7 | 3 |
|
||||
| 6 | 23 | | | | | 9 | 1 |
|
||||
|
||||
46 of the 81 classes contain at least one bite. (Every singleton down tooth
|
||||
must sit in a face holding `≥ 3` of them, so e.g. words with exactly one or two
|
||||
down teeth only survive when those down teeth are paired into a bite — and now
|
||||
only when the paired edges are non-incident, which is why the counts fall
|
||||
sharply from the unrestricted `n = 9` total of 159.)
|
||||
|
||||
## Reproduce
|
||||
|
||||
```sh
|
||||
# from this directory, using the repo .venv
|
||||
../../../.venv/bin/python plot_full_medial_tire_n9.py # figure + index
|
||||
python full_medial_tire_generator.py --min-n 9 --max-n 9 --dedup --show 5
|
||||
```
|
||||
|
||||
`full_medial_tire_generator.py` is the generator (`generate(n, dedup=True)`
|
||||
yields `FullMedialTireGraph` objects); `plot_full_medial_tire_n9.py` draws the
|
||||
atlas.
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
0 word=UUUUUUUUU up=9 down=0 bites=-
|
||||
1 word=UUUUUUDUD up=7 down=2 bites=(6,8)
|
||||
2 word=UUUUUUDDD up=6 down=3 bites=-
|
||||
3 word=UUUUUDUUD up=7 down=2 bites=(5,8)
|
||||
4 word=UUUUUDUDD up=6 down=3 bites=-
|
||||
5 word=UUUUUDDDD up=5 down=4 bites=-
|
||||
6 word=UUUUDUUUD up=7 down=2 bites=(4,8)
|
||||
7 word=UUUUDUUDD up=6 down=3 bites=-
|
||||
8 word=UUUUDUDUD up=6 down=3 bites=-
|
||||
9 word=UUUUDUDDD up=5 down=4 bites=-
|
||||
10 word=UUUUDDUDD up=5 down=4 bites=-
|
||||
11 word=UUUUDDUDD up=5 down=4 bites=(4,8),(5,7)
|
||||
12 word=UUUUDDDDD up=4 down=5 bites=-
|
||||
13 word=UUUUDDDDD up=4 down=5 bites=(4,8)
|
||||
14 word=UUUDUUUDD up=6 down=3 bites=-
|
||||
15 word=UUUDUUDUD up=6 down=3 bites=-
|
||||
16 word=UUUDUUDDD up=5 down=4 bites=-
|
||||
17 word=UUUDUDUDD up=5 down=4 bites=-
|
||||
18 word=UUUDUDUDD up=5 down=4 bites=(3,8),(5,7)
|
||||
19 word=UUUDUDDUD up=5 down=4 bites=-
|
||||
20 word=UUUDUDDUD up=5 down=4 bites=(3,5),(6,8)
|
||||
21 word=UUUDUDDDD up=4 down=5 bites=-
|
||||
22 word=UUUDUDDDD up=4 down=5 bites=(3,5)
|
||||
23 word=UUUDUDDDD up=4 down=5 bites=(3,8)
|
||||
24 word=UUUDDUUDD up=5 down=4 bites=-
|
||||
25 word=UUUDDUUDD up=5 down=4 bites=(3,8),(4,7)
|
||||
26 word=UUUDDUDDD up=4 down=5 bites=-
|
||||
27 word=UUUDDUDDD up=4 down=5 bites=(4,6)
|
||||
28 word=UUUDDUDDD up=4 down=5 bites=(3,8)
|
||||
29 word=UUUDDDDDD up=3 down=6 bites=-
|
||||
30 word=UUUDDDDDD up=3 down=6 bites=(3,8)
|
||||
31 word=UUDUUDUUD up=6 down=3 bites=-
|
||||
32 word=UUDUUDUDD up=5 down=4 bites=-
|
||||
33 word=UUDUUDUDD up=5 down=4 bites=(2,8),(5,7)
|
||||
34 word=UUDUUDDDD up=4 down=5 bites=-
|
||||
35 word=UUDUUDDDD up=4 down=5 bites=(2,5)
|
||||
36 word=UUDUDUUDD up=5 down=4 bites=-
|
||||
37 word=UUDUDUUDD up=5 down=4 bites=(2,8),(4,7)
|
||||
38 word=UUDUDUDUD up=5 down=4 bites=-
|
||||
39 word=UUDUDUDUD up=5 down=4 bites=(2,4),(6,8)
|
||||
40 word=UUDUDUDUD up=5 down=4 bites=(2,8),(4,6)
|
||||
41 word=UUDUDUDDD up=4 down=5 bites=-
|
||||
42 word=UUDUDUDDD up=4 down=5 bites=(4,6)
|
||||
43 word=UUDUDUDDD up=4 down=5 bites=(2,4)
|
||||
44 word=UUDUDUDDD up=4 down=5 bites=(2,8)
|
||||
45 word=UUDUDDUDD up=4 down=5 bites=-
|
||||
46 word=UUDUDDUDD up=4 down=5 bites=(5,7)
|
||||
47 word=UUDUDDUDD up=4 down=5 bites=(2,4)
|
||||
48 word=UUDUDDUDD up=4 down=5 bites=(2,8)
|
||||
49 word=UUDUDDDUD up=4 down=5 bites=-
|
||||
50 word=UUDUDDDUD up=4 down=5 bites=(6,8)
|
||||
51 word=UUDUDDDUD up=4 down=5 bites=(2,8)
|
||||
52 word=UUDUDDDDD up=3 down=6 bites=-
|
||||
53 word=UUDUDDDDD up=3 down=6 bites=(2,4)
|
||||
54 word=UUDUDDDDD up=3 down=6 bites=(2,8)
|
||||
55 word=UUDDUUDDD up=4 down=5 bites=-
|
||||
56 word=UUDDUUDDD up=4 down=5 bites=(3,6)
|
||||
57 word=UUDDUDUDD up=4 down=5 bites=-
|
||||
58 word=UUDDUDUDD up=4 down=5 bites=(5,7)
|
||||
59 word=UUDDUDUDD up=4 down=5 bites=(2,8)
|
||||
60 word=UUDDUDDDD up=3 down=6 bites=-
|
||||
61 word=UUDDUDDDD up=3 down=6 bites=(3,5)
|
||||
62 word=UUDDUDDDD up=3 down=6 bites=(2,8)
|
||||
63 word=UUDDDUDDD up=3 down=6 bites=-
|
||||
64 word=UUDDDUDDD up=3 down=6 bites=(4,6)
|
||||
65 word=UUDDDUDDD up=3 down=6 bites=(2,8)
|
||||
66 word=UUDDDUDDD up=3 down=6 bites=(2,8),(3,7),(4,6)
|
||||
67 word=UDUDUDUDD up=4 down=5 bites=-
|
||||
68 word=UDUDUDUDD up=4 down=5 bites=(5,7)
|
||||
69 word=UDUDUDUDD up=4 down=5 bites=(3,5)
|
||||
70 word=UDUDUDDDD up=3 down=6 bites=-
|
||||
71 word=UDUDUDDDD up=3 down=6 bites=(3,5)
|
||||
72 word=UDUDUDDDD up=3 down=6 bites=(1,3)
|
||||
73 word=UDUDDUDDD up=3 down=6 bites=-
|
||||
74 word=UDUDDUDDD up=3 down=6 bites=(4,6)
|
||||
75 word=UDUDDUDDD up=3 down=6 bites=(1,3)
|
||||
76 word=UDUDDUDDD up=3 down=6 bites=(1,8)
|
||||
77 word=UDUDDUDDD up=3 down=6 bites=(1,8),(3,7),(4,6)
|
||||
78 word=UDDUDDUDD up=3 down=6 bites=-
|
||||
79 word=UDDUDDUDD up=3 down=6 bites=(5,7)
|
||||
80 word=UDDUDDUDD up=3 down=6 bites=(1,8),(2,4),(5,7)
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
"""Draw every full medial tire graph with |A(T)| = 9.
|
||||
|
||||
Generates the dihedral-symmetry classes from ``full_medial_tire_generator`` and
|
||||
draws each one in the tooth style of the paper figures: the annular cycle A(T)
|
||||
as a thick ring of black vertices, up teeth pointing outward (blue apexes),
|
||||
singleton down teeth pointing inward (red apexes), and bites as a single inner
|
||||
apex (dark red) with four spokes to the two paired annular edges.
|
||||
|
||||
Output is a single grid figure (PNG + PDF) plus a text index of the classes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
from full_medial_tire_generator import FullMedialTireGraph, generate
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def vertex_xy(k: int, n: int, radius: float) -> tuple[float, float]:
|
||||
"""Annular vertex k, placed clockwise from the top of the unit circle."""
|
||||
angle = math.pi / 2 - 2 * math.pi * k / n
|
||||
return radius * math.cos(angle), radius * math.sin(angle)
|
||||
|
||||
|
||||
def edge_midpoint_angle(i: int, n: int) -> float:
|
||||
return math.pi / 2 - 2 * math.pi * (i + 0.5) / n
|
||||
|
||||
|
||||
def draw_graph(ax, g: FullMedialTireGraph) -> None:
|
||||
n = g.n
|
||||
ann = [vertex_xy(k, n, 1.0) for k in range(n)]
|
||||
matched = g.bite_edges
|
||||
|
||||
# annular cycle A(T)
|
||||
cyc_x = [p[0] for p in ann] + [ann[0][0]]
|
||||
cyc_y = [p[1] for p in ann] + [ann[0][1]]
|
||||
ax.plot(cyc_x, cyc_y, color="black", lw=1.6, zorder=2)
|
||||
|
||||
# up teeth (outer apexes) and singleton down teeth (inner apexes)
|
||||
for i, tooth in enumerate(g.tooth_word):
|
||||
if tooth == "U":
|
||||
r, color = 1.42, "#2b6cb0"
|
||||
elif i not in matched: # singleton down tooth
|
||||
r, color = 0.58, "#c53030"
|
||||
else:
|
||||
continue
|
||||
ang = edge_midpoint_angle(i, n)
|
||||
apex = (r * math.cos(ang), r * math.sin(ang))
|
||||
a0, a1 = ann[i], ann[(i + 1) % n]
|
||||
ax.plot([apex[0], a0[0]], [apex[1], a0[1]], color="#9a9a9a", lw=0.5, zorder=1)
|
||||
ax.plot([apex[0], a1[0]], [apex[1], a1[1]], color="#9a9a9a", lw=0.5, zorder=1)
|
||||
ax.scatter([apex[0]], [apex[1]], s=14, color=color, zorder=3)
|
||||
|
||||
# bites: one shared inner apex with four spokes
|
||||
for i, j in sorted(g.bites):
|
||||
corners = [ann[i], ann[(i + 1) % n], ann[j], ann[(j + 1) % n]]
|
||||
cx = sum(p[0] for p in corners) / 4.0
|
||||
cy = sum(p[1] for p in corners) / 4.0
|
||||
# pull slightly toward the centre so nested bites stay distinguishable
|
||||
apex = (cx * 0.82, cy * 0.82)
|
||||
for corner in corners:
|
||||
ax.plot([apex[0], corner[0]], [apex[1], corner[1]],
|
||||
color="#9a9a9a", lw=0.5, zorder=1)
|
||||
ax.scatter([apex[0]], [apex[1]], s=26, color="#7b1f1f",
|
||||
edgecolors="black", linewidths=0.4, zorder=4)
|
||||
|
||||
# annular vertices on top
|
||||
ax.scatter([p[0] for p in ann], [p[1] for p in ann],
|
||||
s=10, color="black", zorder=5)
|
||||
|
||||
bites = ",".join(f"{i}{j}" for i, j in sorted(g.bites)) or "-"
|
||||
ax.set_title(f"{g.tooth_word}\n{bites}", fontsize=5.5, pad=1.5)
|
||||
ax.set_xlim(-1.6, 1.6)
|
||||
ax.set_ylim(-1.6, 1.6)
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
graphs = list(generate(args.n, min_up_teeth=args.min_up, dedup=True))
|
||||
print(f"n={args.n}: {len(graphs)} dihedral classes")
|
||||
|
||||
cols = args.cols
|
||||
rows = math.ceil(len(graphs) / cols)
|
||||
fig, axes = plt.subplots(rows, cols, figsize=(cols * 1.45, rows * 1.6))
|
||||
axes = axes.reshape(rows, cols)
|
||||
for idx in range(rows * cols):
|
||||
ax = axes[idx // cols][idx % cols]
|
||||
if idx < len(graphs):
|
||||
draw_graph(ax, graphs[idx])
|
||||
else:
|
||||
ax.axis("off")
|
||||
|
||||
fig.suptitle(
|
||||
f"All full medial tire graphs with $|A(T)|={args.n}$ "
|
||||
f"({len(graphs)} classes up to dihedral symmetry)",
|
||||
fontsize=12, y=0.997,
|
||||
)
|
||||
fig.tight_layout(rect=(0, 0, 1, 0.985))
|
||||
|
||||
png = os.path.join(HERE, f"full_medial_tire_n{args.n}.png")
|
||||
pdf = os.path.join(HERE, f"full_medial_tire_n{args.n}.pdf")
|
||||
fig.savefig(png, dpi=200)
|
||||
fig.savefig(pdf)
|
||||
print(f"wrote {png}")
|
||||
print(f"wrote {pdf}")
|
||||
|
||||
if args.index:
|
||||
index_path = os.path.join(HERE, f"full_medial_tire_n{args.n}_index.txt")
|
||||
with open(index_path, "w") as fh:
|
||||
for idx, g in enumerate(graphs):
|
||||
bites = ",".join(f"({i},{j})" for i, j in sorted(g.bites)) or "-"
|
||||
fh.write(
|
||||
f"{idx:3d} word={g.tooth_word} up={len(g.up_edges)} "
|
||||
f"down={len(g.down_edges)} bites={bites}\n"
|
||||
)
|
||||
print(f"wrote {index_path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--n", type=int, default=9)
|
||||
parser.add_argument("--min-up", type=int, default=3)
|
||||
parser.add_argument("--cols", type=int, default=12)
|
||||
parser.add_argument("--index", action="store_true", default=True)
|
||||
run(parser.parse_args())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user