Compare commits

...

6 Commits

Author SHA1 Message Date
didericis b605931678 Treat each disjoint annular cycle as its own full medial tire graph
A tread's annular frontier can split into several disjoint cycles; each
is now recognised as a separate full medial tire graph instead of
disqualifying the whole tread.

- recognise() returns a list of (g, bij), one per annular cycle
  component; add annular_cycle_components() and _recognise_one(), and
  iterate components in iter_pieces().
- Key tires/results by (depth, component) throughout both experiment
  drivers: _label_treads chains each tire to a parent-depth down tooth
  sharing its apex; _cap_cut/_assemble_cut_graph/to_json/summary and the
  dual-cut collectors/draws follow suit.

Source vertex selection for the dual-cut experiment now deep-embeds a
random face and roots at the outer-cap vertex. The source-dual figure
labels the source-graph vertices, highlights the entry medial vertex,
and uses a cap-rooted concentric layout.

For seed 7 / face (14,15,19) this recognises treads 3 and 4 as two
tires each (3.0,3.1,4.0,4.1), so every dual face is now cut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 12:01:36 -04:00
didericis 7554582056 Draw tread 0 (the source cap) in the dual-cut experiment
Add draw_cap_png and a --cap-png flag: render tread 0 as a wheel (source
hub, link-cycle rim, cap triangles filled, cap cut marked) from the
extract_tread roles, since tread 0 is skipped by tire recognition (a wheel
has no up teeth). Render funcD seed7's cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:06:49 -04:00
didericis 192d97a31d Regenerate funcD seed7 figures with the apex-cut model
Reflects cutting up-tooth apexes (except entry teeth): seed7 removes 17
source-dual edges, so its dual retains cycles (not a tree).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:52:28 -04:00
didericis 4e92dde36e Cut up-tooth apexes (except entry teeth) in the dual-cut experiment
Duplicate the apex medial vertex of every singleton up tooth across all
recognised treads -- except each tread's entry tooth, whose apex is left
intact -- in addition to the closing annular-vertex cuts.

For seed59 (source 5) this removes 19 = n-1 source-dual edges and the
remaining dual is a tree (verified for every source/entry choice). The
tree property holds exactly when n-1 distinct edges are cut; some graphs
(e.g. seed7, cutting 17) fall short and retain cycles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:50:56 -04:00
didericis 0a3d7b2615 Draw each full medial tire cut from the dual-cut experiment
Add draw_tire_cuts_png (and a --tire-png flag): one panel per recognised
tread showing the annular cycle, up/down/bite teeth, walk-depth labels, and
cut slits, ported from medial_tire_cut_labelling.to_tikz. Render the
function-D (seed 7) graph's tire cuts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:37:42 -04:00
didericis 367b5adc71 Add source-dual cut experiment with chained entry points
Reads the chained medial tire cut off as a source-dual cut (planar dual of
G with the cut edges removed), as in seed59_min5_dual_cut_1.png, and counts
the missing dual edges around each dual face (vertex of G).

Four chained entry points, broad to narrow control:
  - random_dual_cut: random min-degree-5 maximal planar graph -> ...
  - dual_cut_random_source: random level source -> ...
  - dual_cut_random_entry: random root entry tooth -> ...
  - medial_tire_dual_cut: worker chaining the walk-depth labelling/cut.

Refactor _label_treads to accept an optional root_entry_edge (default
preserves the arbitrary-up-tooth behaviour) so the worker can pin the entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:33:15 -04:00
9 changed files with 942 additions and 107 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

@@ -0,0 +1,765 @@
"""Source-dual cut from a chained medial tire cut.
Companion to ``run_medial_tire_cut_experiment.py``. Where that script reports
the cut graph of M(G), this one takes the same chained walk-depth labelling and
cut and reads it off as a *source-dual* cut: the planar dual of the source
triangulation G with the cut edges removed, as drawn for
``seed59_min5_dual_cut_1.png``.
The dual of a plane triangulation G has one node per triangular face and one
edge per primal edge (joining the two faces that share it). Its faces are the
*vertices* of G, each bounded by ``deg(v)`` dual edges. A medial tire cut at an
annular medial vertex removes the dual edge of the corresponding primal edge;
the interesting quantity is how many of those removed (``missing``) dual edges
surround each dual face (vertex of G). For ``seed59`` at source 5 the maximum
is 3, around the degree-9 vertex 3.
The level source is chosen by deep embedding: pick a random face of G, take the
deep embedding G' relative to that face (subdividing every neutral face,
including the chosen one), and use the outer-cap vertex x* placed inside the
chosen face as the source. The whole dual cut is then read off G'.
Four chained entry points (broad to narrow control):
* ``random_dual_cut(n, ...)`` -- find a random maximal planar graph of a given
minimum degree, then defer to ``dual_cut_random_face``.
* ``dual_cut_random_face(G, ...)`` -- choose a random face, deep-embed
relative to it, and use the cap vertex as the source, then defer to
``dual_cut_random_entry``.
* ``dual_cut_random_entry(G', cap, ...)`` -- choose a random root entry
tooth, then defer to ``medial_tire_dual_cut``.
* ``medial_tire_dual_cut(G', source, entry_edge)`` -- the worker: chain the
walk-depth labelling/cut from the given root entry tooth and assemble the
source-dual cut.
Run with the repo venv (networkx; matplotlib only for ``--png``):
``.venv/bin/python``.
"""
from __future__ import annotations
import argparse
import os
import random
import sys
from collections import defaultdict
import networkx as nx
_HERE = os.path.dirname(os.path.abspath(__file__))
_MTD = os.path.normpath(os.path.join(
_HERE, "..", "..",
"medial_tire_decompositions_of_plane_triangulations", "experiments"))
sys.path.insert(0, _MTD)
sys.path.insert(0, _HERE)
from tire_realization_analysis import ( # noqa: E402
ekey, extract_tread, medial_graph, medial_tire_facemodel,
recognise, triangular_faces,
)
from run_medial_tire_cut_experiment import ( # noqa: E402
_assemble_cut_graph, _cap_cut, _label_treads,
random_maximal_planar_min_degree,
)
# --------------------------------------------------------------------------- #
# Tread recognition and the source-dual graph.
# --------------------------------------------------------------------------- #
def _build_treads(faces, levels):
"""Recognise the full medial tire graph(s) of every BFS-level tread.
A tread depth whose annular frontier splits into several disjoint cycles
yields one tire per cycle. Returns ``(treads, skipped)`` where ``treads``
maps ``(depth, component)`` to the recognised ``(g, bij)`` and ``skipped``
lists ``(d, reason)`` for the depths that produced no tire.
"""
treads, skipped = {}, []
for d in range(max(levels.values())):
tread = extract_tread(faces, levels, d)
if tread is None:
skipped.append((d, "no tread faces"))
continue
if len(tread["up"]) < 3:
skipped.append((d, f"only {len(tread['up'])} up teeth"))
continue
tires = recognise(medial_tire_facemodel(tread["tread_faces"]), tread)
if not tires:
skipped.append((d, "no annular cycle recognised as a tire"))
continue
for c, gb in enumerate(tires):
treads[(d, c)] = gb
return treads, skipped
def root_entry_choices(G, source):
"""Edge indices of the root tread's up teeth -- the eligible entry teeth.
Empty when ``source`` induces no recognised root tread.
"""
faces, _ = triangular_faces(G)
levels = nx.single_source_shortest_path_length(G, source)
treads, _ = _build_treads(faces, levels)
if not treads:
return []
g, _bij = treads[min(treads)]
return sorted(g.up_edges)
# --------------------------------------------------------------------------- #
# Deep embedding (relative to a chosen face) and its outer-cap vertex.
# --------------------------------------------------------------------------- #
def _plane_depth(G, outer_cycle):
"""Plane depth of every vertex of ``G`` relative to ``outer_cycle``: the
graph distance to the nearest outer-cycle vertex (outer cycle = depth 0).
Mirrors ``plane_depth_sequencing.get_plane_depth_labelling`` -- attach a
temporary super-source to the outer cycle, BFS, and subtract one."""
tmp = G.copy()
s = max(G.nodes()) + 1
tmp.add_node(s)
tmp.add_edges_from((s, v) for v in outer_cycle)
dist = nx.single_source_shortest_path_length(tmp, s)
return {v: dist[v] - 1 for v in G.nodes()}
def deep_embedding(G, face):
"""Deep embedding of maximal planar ``G`` relative to triangular ``face``.
Networkx port of ``plane_depth_sequencing.extended_deep_embedding``: with
``face`` taken as the outer face, subdivide every *neutral* triangular face
(all three vertices at equal plane depth) -- including ``face`` itself -- by
inserting a new vertex adjacent to its three corners. The vertex inserted
inside ``face`` is the outer-cap vertex x* (depth -1); the rest sit one
level deeper than the face they cap.
Returns ``(G_prime, cap_vertex, depth)``.
"""
faces, _ = triangular_faces(G)
outer = frozenset(face)
depth = _plane_depth(G, face)
G_prime = G.copy()
nxt = max(G.nodes()) + 1
cap_vertex = None
for f in faces:
assert len(f) == 3, f"non-triangular face {f} (graph not maximal planar?)"
a, b, c = f
if depth[a] == depth[b] == depth[c]:
x = nxt
nxt += 1
G_prime.add_node(x)
G_prime.add_edges_from([(x, a), (x, b), (x, c)])
if frozenset(f) == outer:
cap_vertex = x
depth[x] = -1
else:
depth[x] = depth[a] + 1
if cap_vertex is None:
raise ValueError(f"face {face} is not a face of G")
return G_prime, cap_vertex, depth
def deep_embed_random_face(G, rng=None):
"""Pick a random triangular face of ``G`` and deep-embed relative to it.
Returns ``(G_prime, cap_vertex, face)``; ``cap_vertex`` is the outer-cap
vertex used as the level source."""
rng = rng or random.Random()
faces, _ = triangular_faces(G)
face = rng.choice(faces)
G_prime, cap, _depth = deep_embedding(G, face)
return G_prime, cap, face
def source_dual(G, faces):
"""The planar dual of triangulation ``G``: one node per face, one edge per
primal edge (tagged ``primal``). Faces are indexed as in ``faces``."""
edge_faces = defaultdict(list)
for fi, f in enumerate(faces):
for a, b in ((f[0], f[1]), (f[1], f[2]), (f[2], f[0])):
edge_faces[ekey(a, b)].append(fi)
D = nx.Graph()
D.add_nodes_from(range(len(faces)))
for e, fs in edge_faces.items():
if len(fs) == 2:
D.add_edge(fs[0], fs[1], primal=e)
return D
def annular_cut_edges(results, cap_cuts):
"""Primal edges whose dual edge a *closing* cut removes: the cap cut plus
each tread's annular-vertex duplications."""
removed = set()
for c in cap_cuts or []:
removed.add(c["medial_vertex"])
for key in sorted(results):
bij = results[key]["bij"]
for c in results[key]["cuts"]:
if c.vertex is not None:
removed.add(bij[f"a{c.vertex}"])
return removed
def up_apex_cut_edges(results):
"""Primal edges whose dual edge the apex duplications remove: the apex
medial vertex of every (singleton) up tooth across all treads, except the
entry tooth of each tread (its apex is not duplicated)."""
removed = set()
for key in sorted(results):
g, bij = results[key]["g"], results[key]["bij"]
entry = results[key]["entry_edge"]
for i in g.up_edges:
if i == entry:
continue
removed.add(bij[f"u{i}"])
return removed
def removed_dual_edges(results, cap_cuts):
"""All primal edges whose dual edge the cut removes: the closing annular
cuts together with the up-tooth apex duplications."""
return annular_cut_edges(results, cap_cuts) | up_apex_cut_edges(results)
def dual_face_missing(G, removed):
"""For each dual face (vertex ``v`` of ``G``), the number of bounding dual
edges removed by the cut."""
return {v: sum(1 for w in G.neighbors(v) if ekey(v, w) in removed)
for v in G.nodes()}
# --------------------------------------------------------------------------- #
# The four chained entry points.
# --------------------------------------------------------------------------- #
def medial_tire_dual_cut(G, source, entry_edge):
"""Chain the walk-depth labelling/cut from root entry tooth ``entry_edge``
and assemble the source-dual cut of ``G`` at level ``source``.
``entry_edge`` must be an up tooth of the root (lowest recognised) tread;
see ``root_entry_choices``. Returns a structured result dict.
"""
faces, emb = triangular_faces(G)
M = medial_graph(G)
levels = nx.single_source_shortest_path_length(G, source)
treads, skipped = _build_treads(faces, levels)
if not treads:
raise ValueError(f"level source {source} induces no recognised tread")
g_root = treads[min(treads)][0]
if entry_edge not in g_root.up_edges:
raise ValueError(
f"entry edge {entry_edge} is not an up tooth of the root tread "
f"(choices: {sorted(g_root.up_edges)})")
results = {}
_label_treads(treads, results, root_entry_edge=entry_edge)
cap_cuts = _cap_cut(G, emb, source, levels, results)
cut_graph, labels, warnings = _assemble_cut_graph(M, results, cap_cuts=cap_cuts)
dual = source_dual(G, faces)
annular = annular_cut_edges(results, cap_cuts)
apex = up_apex_cut_edges(results)
removed = annular | apex
missing = dual_face_missing(G, removed)
# The first entry: the medial vertex (primal edge) of the root tread's
# entry up-tooth apex. This apex is *not* duplicated, so it is the seam the
# chained walk starts from rather than a removed edge.
root = min(results)
entry_medial = results[root]["bij"][f"u{entry_edge}"]
return {
"G": G, "M": M, "source": source, "entry_edge": entry_edge,
"entry_medial_vertex": entry_medial,
"faces": faces, "outer_face": 0,
"levels": levels, "treads": treads, "skipped": skipped,
"results": results, "cap_cuts": cap_cuts, "cut_graph": cut_graph,
"labels": labels, "warnings": warnings,
"dual": dual, "removed_dual_edges": removed,
"annular_cut_edges": annular, "apex_cut_edges": apex,
"dual_face_missing": missing,
"max_missing": max(missing.values()) if missing else 0,
}
def dual_cut_random_entry(G, source, rng=None):
"""Pick a random root entry tooth at ``source``, then ``medial_tire_dual_cut``."""
rng = rng or random.Random()
choices = root_entry_choices(G, source)
if not choices:
raise ValueError(f"level source {source} induces no recognised root tread")
return medial_tire_dual_cut(G, source, rng.choice(choices))
def dual_cut_random_face(G, rng=None):
"""Pick a random face of ``G``, deep-embed relative to it, and cut from the
resulting outer-cap vertex, then ``dual_cut_random_entry``.
Faces are tried in random order; the first whose cap vertex induces a
recognised root tread is used. The dual cut is then read off the deep
embedding ``G'`` (stored as ``result["G"]``); the original triangulation is
kept as ``result["base_graph"]``."""
rng = rng or random.Random()
faces, _ = triangular_faces(G)
order = list(faces)
rng.shuffle(order)
for face in order:
G_prime, cap, depth = deep_embedding(G, face)
if root_entry_choices(G_prime, cap):
result = dual_cut_random_entry(G_prime, cap, rng=rng)
result["base_graph"] = G
result["chosen_face"] = tuple(face)
result["cap_vertex"] = cap
result["deep_depth"] = depth
return result
raise ValueError("no face's cap vertex induces a recognised root tread")
def random_dual_cut(n=20, seed=0, rng=None, min_degree=5, flips=400, attempts=1000):
"""Find a random maximal planar graph of minimum degree ``min_degree``, then
``dual_cut_random_face``.
``seed`` drives the graph sample; ``rng`` (defaulting to ``Random(seed)``)
drives the random face, deep embedding, and entry choices, so the whole
pipeline is reproducible from ``(n, seed)``.
"""
rng = rng or random.Random(seed)
G, graph_seed = random_maximal_planar_min_degree(
n, seed, flips=flips, min_degree=min_degree, attempts=attempts)
result = dual_cut_random_face(G, rng=rng)
result["graph_seed"] = graph_seed
result["base_min_degree"] = min(dict(G.degree()).values())
result["min_degree"] = min(dict(result["G"].degree()).values())
return result
# --------------------------------------------------------------------------- #
# Reporting and (optional) rendering.
# --------------------------------------------------------------------------- #
def summary(result):
G, missing = result["G"], result["dual_face_missing"]
removed = result["removed_dual_edges"]
hist = defaultdict(int)
for k in missing.values():
hist[k] += 1
base = result.get("base_graph")
lines = [
f"source-dual cut: n={G.number_of_nodes()} "
f"(deep embedding of base n="
f"{base.number_of_nodes() if base is not None else '?'}) "
f"graph_seed={result.get('graph_seed', '?')} "
f"min_degree={result.get('min_degree', min(dict(G.degree()).values()))}",
f"chosen face: {result.get('chosen_face', '?')} "
f"-> cap vertex x*={result.get('cap_vertex', result['source'])}",
f"level source: cap vertex {result['source']} "
f"root entry tooth: e{result['entry_edge']}",
f"recognised tires (depth.component): "
f"{[f'{d}.{c}' for d, c in sorted(result['treads'])]} "
f"skipped: {result['skipped']}",
f"removed source-dual edges ({len(removed)}): "
f"{len(result['annular_cut_edges'])} annular/cap + "
f"{len(result['apex_cut_edges'])} up-tooth apex",
f" annular/cap: {sorted(result['annular_cut_edges'])}",
f" up apexes: {sorted(result['apex_cut_edges'])}",
f"dual-face missing-edge histogram (count by #removed around the dual "
f"face): {dict(sorted(hist.items()))} max={result['max_missing']}",
]
for v in sorted(missing, key=lambda v: (-missing[v], v)):
if missing[v]:
inc = [ekey(v, w) for w in G.neighbors(v) if ekey(v, w) in removed]
lines.append(f" dual face v{v} (deg {G.degree(v)}): "
f"{missing[v]} missing -> {inc}")
return "\n".join(lines)
def _radial_source_layout(G, source, levels):
"""Concentric ('onion') layout rooted at the cap ``source``: radius grows
with BFS level so the depth rings are actual circles, and each ring's
angular order is inherited from its lower-level neighbours to keep the
nesting legible. This matches the cap-source construction, where the BFS
rings are exactly the plane-depth rings."""
import math
max_level = max(levels.values()) or 1
ring = defaultdict(list)
for v, d in levels.items():
ring[d].append(v)
angle = {source: 0.0}
pos = {source: (0.0, 0.0)}
for d in range(1, max_level + 1):
verts = ring[d]
prov = {}
for v in verts:
pa = [angle[w] for w in G.neighbors(v)
if levels.get(w) == d - 1 and w in angle]
if pa:
sx = sum(math.cos(a) for a in pa)
sy = sum(math.sin(a) for a in pa)
prov[v] = math.atan2(sy, sx)
else:
prov[v] = 0.0
verts.sort(key=lambda v: prov[v] % (2 * math.pi))
k = len(verts)
base = prov[verts[0]] if verts else 0.0
r = d / max_level
for i, v in enumerate(verts):
a = base + 2 * math.pi * i / k
angle[v] = a
pos[v] = (r * math.cos(a), r * math.sin(a))
return pos
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
G, faces, dual = result["G"], result["faces"], result["dual"]
removed = result["removed_dual_edges"]
missing = result["dual_face_missing"]
source = result["source"]
entry_medial = result.get("entry_medial_vertex")
pos_v = _radial_source_layout(G, source, result["levels"])
def centroid(fi):
xs = [pos_v[u][0] for u in faces[fi]]
ys = [pos_v[u][1] for u in faces[fi]]
return (sum(xs) / 3.0, sum(ys) / 3.0)
pos = {fi: centroid(fi) for fi in dual.nodes()}
fig, ax = plt.subplots(figsize=(7.6, 7.6))
# primal (source) graph, faint, for orientation
for u, v in G.edges():
ax.plot([pos_v[u][0], pos_v[v][0]], [pos_v[u][1], pos_v[v][1]],
color="0.85", lw=0.5, zorder=0)
# the entry medial vertex = a primal edge; highlight that primal edge and
# the dual edge crossing it.
if entry_medial is not None:
eu, ev = entry_medial
ax.plot([pos_v[eu][0], pos_v[ev][0]], [pos_v[eu][1], pos_v[ev][1]],
color="#1b9e44", lw=2.6, zorder=2, solid_capstyle="round")
for u, v, data in dual.edges(data=True):
cut = data["primal"] in removed
is_entry = entry_medial is not None and data["primal"] == entry_medial
if is_entry:
ax.plot([pos[u][0], pos[v][0]], [pos[u][1], pos[v][1]],
color="#1b9e44", lw=2.6, zorder=4, solid_capstyle="round")
mx, my = (pos[u][0] + pos[v][0]) / 2, (pos[u][1] + pos[v][1]) / 2
ax.plot(mx, my, "*", ms=15, mfc="#1b9e44", mec="white",
mew=0.7, zorder=5)
ax.text(mx, my - 0.06, "entry", color="#1b9e44", fontsize=8,
fontweight="bold", ha="center", va="top", zorder=5)
else:
ax.plot([pos[u][0], pos[v][0]], [pos[u][1], pos[v][1]],
color="0.80" if cut else "0.25",
lw=1.0 if cut else 1.3,
linestyle=(0, (2, 2)) if cut else "solid", zorder=1)
for fi in dual.nodes():
x, y = pos[fi]
ax.plot(x, y, "o", ms=4, color="#3a6ea5", zorder=3)
# label each source-graph vertex by its id; the cap source is flagged.
for v in G.nodes():
x, y = pos_v[v]
is_src = v == source
ax.text(x, y, str(v),
color="#0b6", fontsize=8 if not is_src else 9,
fontweight="bold" if is_src else "normal",
ha="center", va="center", zorder=6,
bbox=dict(boxstyle="round,pad=0.12",
fc="#eafff2" if is_src else "white",
ec="#1b9e44" if is_src else "0.6", lw=0.7))
# missing-edge count, offset above-right of the vertex label.
m = missing[v]
if m:
ax.text(x + 0.045, y + 0.045, str(m), color="#b03030", fontsize=7,
ha="left", va="bottom", zorder=7,
bbox=dict(boxstyle="circle,pad=0.05", fc="white",
ec="#b03030", lw=0.6))
ax.set_title(f"source-dual cut (cap source {source}, entry "
f"e{result['entry_edge']} = medial vtx {entry_medial}); "
f"gray = edges missing after cuts\n"
f"green star = first entry medial vertex; red numbers = "
f"#missing dual edges around each dual face; "
f"max {result['max_missing']}", fontsize=9)
ax.set_aspect("equal")
ax.axis("off")
fig.tight_layout()
fig.savefig(path, dpi=150)
plt.close(fig)
def _tire_coords(g, r_ann=1.0, r_up=1.46, r_down=0.60):
"""Annular/teeth coordinates for one tread, matching
``medial_tire_cut_labelling.to_tikz``: a_0 at the top, k increasing CW."""
import math
n = g.n
def ang(k):
return math.radians(90.0 - k * 360.0 / n)
def mid(i):
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 g.up_edges:
a = mid(i)
pos[f"u{i}"] = (r_up * math.cos(a), r_up * math.sin(a))
for i in g.singleton_down_edges:
a = mid(i)
pos[f"d{i}"] = (r_down * math.cos(a), r_down * math.sin(a))
for (i, j) in g.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 _draw_tread(ax, g, depth, cuts, entry_edge, title):
"""Draw one full medial tire cut on ``ax`` (annular cycle, teeth, walk-depth
labels, cut slits), mirroring ``medial_tire_cut_labelling.to_tikz``."""
import math
n = g.n
pos = _tire_coords(g)
def seg(a, b, **kw):
ax.plot([pos[a][0], pos[b][0]], [pos[a][1], pos[b][1]], **kw)
# annular cycle
xs = [pos[f"a{k}"][0] for k in range(n)] + [pos["a0"][0]]
ys = [pos[f"a{k}"][1] for k in range(n)] + [pos["a0"][1]]
ax.plot(xs, ys, color="black", lw=1.4, zorder=1)
# spokes (teeth)
for i in g.up_edges:
seg(f"u{i}", f"a{i}", color="0.55", lw=0.6, zorder=1)
seg(f"u{i}", f"a{(i + 1) % n}", color="0.55", lw=0.6, zorder=1)
for i in g.singleton_down_edges:
seg(f"d{i}", f"a{i}", color="0.55", lw=0.6, zorder=1)
seg(f"d{i}", f"a{(i + 1) % n}", color="0.55", lw=0.6, zorder=1)
for (i, j) in g.bites:
apex = f"p{i}_{j}"
for e in (i, j):
seg(apex, f"a{e}", color="0.55", lw=0.6, zorder=1)
seg(apex, f"a{(e + 1) % n}", color="0.55", lw=0.6, zorder=1)
# vertices
for k in range(n):
ax.plot(*pos[f"a{k}"], "o", ms=3, color="black", zorder=3)
for i in g.up_edges:
ax.plot(*pos[f"u{i}"], "o", ms=5, mfc="#cfe0f3", mec="#3a6ea5", zorder=3)
for i in g.singleton_down_edges:
ax.plot(*pos[f"d{i}"], "o", ms=5, mfc="#f3cfcf", mec="#a53a3a", zorder=3)
for (i, j) in g.bites:
ax.plot(*pos[f"p{i}_{j}"], "o", ms=6, mfc="#e8a0a0", mec="#a53a3a", zorder=3)
# walk-depth labels along each spoke
if depth is not None:
for edge in range(n):
ax_, ay = pos[g.apex_of_edge(edge)]
a, b = pos[f"a{edge}"], pos[f"a{(edge + 1) % n}"]
mx, my = 0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1])
lx, ly = ax_ + 0.5 * (mx - ax_), ay + 0.5 * (my - ay)
ax.text(lx, ly, str(depth[edge]), fontsize=7, fontweight="bold",
ha="center", va="center", zorder=4)
# annular-vertex cut slits (radial)
for c in cuts or []:
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)
ax.plot([vx - dx, vx + dx], [vy - dy, vy + dy],
color="#cc2020", lw=2.0, zorder=5)
ax.text(vx + 0.34 * math.cos(rad), vy + 0.34 * math.sin(rad),
f"cut {c.order + 1}", fontsize=6, color="#cc2020",
ha="center", va="center", zorder=5)
# up-tooth apex duplications (slit tangential, across the apex marker);
# the entry tooth's apex is not duplicated
for i in g.up_edges:
if i == entry_edge:
continue
vx, vy = pos[f"u{i}"]
rad = math.atan2(vy, vx)
tx, ty = -math.sin(rad), math.cos(rad) # tangential
ax.plot([vx - 0.12 * tx, vx + 0.12 * tx],
[vy - 0.12 * ty, vy + 0.12 * ty],
color="#cc2020", lw=2.0, zorder=6)
# entry marker
if entry_edge is not None:
ex, ey = pos[g.apex_of_edge(entry_edge)]
rad = math.atan2(ey, ex)
ax.text(ex + 0.34 * math.cos(rad), ey + 0.34 * math.sin(rad),
"entry", fontsize=6, color="#3a6ea5", ha="center", va="center")
ax.set_title(title, fontsize=8)
ax.set_aspect("equal")
ax.axis("off")
def draw_tire_cuts_png(result, path):
"""Render every recognised tire's full medial tire cut, one panel each.
A tread depth with several disjoint annular cycles contributes one panel
per cycle, labelled ``tread d.c``."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
res = result["results"]
keys = sorted(res)
if not keys:
raise ValueError("no recognised tires to draw")
fig, axes = plt.subplots(1, len(keys), figsize=(5.2 * len(keys), 5.4))
if len(keys) == 1:
axes = [axes]
for ax, key in zip(axes, keys):
d, comp = key
rec = res[key]
g = rec["g"]
title = (f"tread {d}.{comp}: |A(T)|={g.n} word={g.tooth_word}\n"
f"bites={sorted(g.bites)} entry=e{rec['entry_edge']} "
f"start_depth={rec['start_depth']} cuts={len(rec['cuts'])}")
_draw_tread(ax, g, rec["depth"], rec["cuts"], rec["entry_edge"], title)
fig.suptitle(f"full medial tire cuts -- source {result['source']}, "
f"root entry e{result['entry_edge']}", fontsize=10)
fig.tight_layout()
fig.savefig(path, dpi=150)
plt.close(fig)
def draw_cap_png(result, path):
"""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
cut marked. Tread 0 is skipped by tire recognition (a wheel has no up
teeth), so this draws the ``extract_tread`` roles directly."""
import math
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
G, source = result["G"], result["source"]
faces, emb = triangular_faces(G)
levels = nx.single_source_shortest_path_length(G, source)
tr = extract_tread(faces, levels, 0)
if tr is None:
raise ValueError("no tread-0 (cap) faces")
link = list(emb.neighbors_cw_order(source))
cap_cuts = {c["medial_vertex"] for c in result.get("cap_cuts", [])}
pos = {source: (0.0, 0.0)}
k = len(link)
for i, v in enumerate(link):
a = math.radians(90 - i * 360.0 / k)
pos[v] = (math.cos(a), math.sin(a))
fig, ax = plt.subplots(figsize=(6.5, 6.8))
for f in tr["tread_faces"]:
if all(v in pos for v in f):
xy = [pos[v] for v in f]
ax.fill([p[0] for p in xy], [p[1] for p in xy],
color="#eef3fa", zorder=0)
def edge(u, v, **kw):
ax.plot([pos[u][0], pos[v][0]], [pos[u][1], pos[v][1]], **kw)
for u, v in tr["annular"]: # spokes (source -> link)
edge(u, v, color="0.45", lw=1.0, zorder=1)
for u, v in tr["down"]: # link cycle (down-tooth bases)
edge(u, v, color="black", lw=1.6, zorder=1)
ax.plot(*pos[source], "o", ms=11, mfc="#cfe0f3", mec="#3a6ea5", zorder=4)
ax.text(*pos[source], str(source), ha="center", va="center", fontsize=9,
fontweight="bold", color="#234", zorder=5)
for v in link:
ax.plot(*pos[v], "o", ms=9, mfc="white", mec="black", zorder=4)
x, y = pos[v]
ax.text(x * 1.13, y * 1.13, str(v), ha="center", va="center", fontsize=9)
for u, v in list(tr["annular"]) + list(tr["down"]):
mx, my = (pos[u][0] + pos[v][0]) / 2, (pos[u][1] + pos[v][1]) / 2
cut = ekey(u, v) in cap_cuts
ax.plot(mx, my, "s", ms=5, mfc=("#cc2020" if cut else "#888"),
mec="none", zorder=3)
if cut:
dx, dy = pos[v][0] - pos[u][0], pos[v][1] - pos[u][1]
L = math.hypot(dx, dy) or 1.0
px, py = -dy / L * 0.13, dx / L * 0.13
ax.plot([mx - px, mx + px], [my - py, my + py],
color="#cc2020", lw=2.2, zorder=5)
ax.text(mx + 0.12, my, "cap cut", color="#cc2020", fontsize=7,
va="center")
ax.set_title(f"tread 0 (source cap) -- source {source}, link {link}\n"
f"{len(tr['tread_faces'])} cap triangles; no up teeth (skipped); "
f"down teeth = link cycle", fontsize=8)
ax.set_aspect("equal")
ax.axis("off")
ax.set_xlim(-1.4, 1.4)
ax.set_ylim(-1.4, 1.4)
fig.tight_layout()
fig.savefig(path, dpi=150)
plt.close(fig)
def main():
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("-n", type=int, default=20, help="number of vertices")
parser.add_argument("--seed", type=int, default=0, help="graph sample seed")
parser.add_argument("--min-degree", type=int, default=5)
parser.add_argument("--face", type=str, default=None,
help="fix the chosen face as 'a,b,c' (default: random "
"via rng); the deep embedding's cap vertex is the "
"source")
parser.add_argument("--entry", type=int, default=None,
help="fix the root entry tooth (requires --face)")
parser.add_argument("--png", metavar="PATH", help="render the dual cut to PNG")
parser.add_argument("--tire-png", metavar="PATH",
help="render each full medial tire cut to PNG")
parser.add_argument("--cap-png", metavar="PATH",
help="render tread 0 (the source cap) to PNG")
args = parser.parse_args()
rng = random.Random(args.seed)
if args.face is not None:
G, graph_seed = random_maximal_planar_min_degree(
args.n, args.seed, min_degree=args.min_degree)
face = tuple(int(x) for x in args.face.split(","))
G_prime, cap, depth = deep_embedding(G, face)
if args.entry is not None:
result = medial_tire_dual_cut(G_prime, cap, args.entry)
else:
result = dual_cut_random_entry(G_prime, cap, rng=rng)
result["base_graph"] = G
result["chosen_face"] = face
result["cap_vertex"] = cap
result["deep_depth"] = depth
result["graph_seed"] = graph_seed
result["base_min_degree"] = min(dict(G.degree()).values())
result["min_degree"] = min(dict(G_prime.degree()).values())
else:
result = random_dual_cut(n=args.n, seed=args.seed,
rng=rng, min_degree=args.min_degree)
print(summary(result))
if args.png:
draw_png(result, args.png)
print(f"wrote {args.png}")
if args.tire_png:
draw_tire_cuts_png(result, args.tire_png)
print(f"wrote {args.tire_png}")
if args.cap_png:
draw_cap_png(result, args.cap_png)
print(f"wrote {args.cap_png}")
if __name__ == "__main__":
main()
@@ -68,31 +68,56 @@ def _apex_vertex(g, bij, edge):
return bij[g.apex_of_edge(edge)]
def _label_treads(treads, results):
"""Fill ``results[d]`` with the walk-depth labelling and cuts for each
recognised tread ``d``, chaining child entries to parent down teeth."""
for d in sorted(treads):
g, bij = treads[d]
parent = treads.get(d - 1)
if parent is None:
entry_edge, start_depth = g.up_edges[0], 0 # arbitrary root entry
else:
pg, pbij = parent
pdepth = results[d - 1]["depth"]
# parent down teeth, lowest walk depth first
down = sorted((pdepth[k], _apex_vertex(pg, pbij, k))
for k in pg.down_edges)
child_up_apex = {bij[f"u{m}"]: m for m in g.up_edges}
entry_edge = start_depth = None
for value, apex in down:
if apex in child_up_apex:
entry_edge, start_depth = child_up_apex[apex], value + 1
break
if entry_edge is None: # no shared apex (degenerate); root-style
entry_edge, start_depth = g.up_edges[0], 0
depth, cuts = label_and_cut(g, entry_edge, start_depth=start_depth)
results[d] = {"g": g, "bij": bij, "entry_edge": entry_edge,
"start_depth": start_depth, "depth": depth, "cuts": cuts}
def _label_treads(treads, results, root_entry_edge=None):
"""Fill ``results[(d, c)]`` with the walk-depth labelling and cuts for every
recognised tire ``c`` of every tread depth ``d``, chaining child entries to
parent down teeth.
``treads`` maps ``(depth, component)`` -> ``(g, bij)``; a tread depth may
carry several tires (one per disjoint annular cycle). The root tire
``(root_d, 0)`` is entered at ``root_entry_edge`` when given -- it must be
one of that tire's up teeth -- otherwise at an arbitrary up tooth. Each
other tire chains to whichever parent-depth down tooth (across all parent
tires) shares its apex, at the lowest parent walk depth.
"""
if not treads:
return
depths = sorted({k[0] for k in treads})
root_d = depths[0]
for d in depths:
# apex medial vertex -> child start depth, over all parent-depth tires
parent_down = {}
for pk in (k for k in treads if k[0] == d - 1):
pg, pbij = treads[pk]
pdepth = results[pk]["depth"]
for e in pg.down_edges:
apex = _apex_vertex(pg, pbij, e)
value = pdepth[e] + 1
if apex not in parent_down or value < parent_down[apex]:
parent_down[apex] = value
has_parent = any(k[0] == d - 1 for k in treads)
for key in sorted(k for k in treads if k[0] == d):
g, bij = treads[key]
if not has_parent:
if (key == (root_d, 0) and root_entry_edge is not None
and root_entry_edge in g.up_edges):
entry_edge, start_depth = root_entry_edge, 0
else:
entry_edge, start_depth = g.up_edges[0], 0 # arbitrary entry
else:
child_up_apex = {bij[f"u{m}"]: m for m in g.up_edges}
best = None
for apex, value in parent_down.items():
if apex in child_up_apex and (best is None or value < best[1]):
best = (child_up_apex[apex], value)
if best is not None: # chains to a parent down tooth
entry_edge, start_depth = best
else: # no shared apex (degenerate); root-style
entry_edge, start_depth = g.up_edges[0], 0
depth, cuts = label_and_cut(g, entry_edge, start_depth=start_depth)
results[key] = {"g": g, "bij": bij, "entry_edge": entry_edge,
"start_depth": start_depth, "depth": depth,
"cuts": cuts}
def _cap_cut(G, emb, source, levels, results):
@@ -173,21 +198,22 @@ def _assemble_cut_graph(M, results, cap_cuts=None):
**{v: copy_a for v in c["neighbours_a"]},
**{v: copy_b for v in c["neighbours_b"]},
}
for d in sorted(results):
g, bij = results[d]["g"], results[d]["bij"]
for key in sorted(results):
td = key[0]
g, bij = results[key]["g"], results[key]["bij"]
n = g.n
for c in results[d]["cuts"]:
for c in results[key]["cuts"]:
kk = c.vertex
if kk is None:
continue
mv = bij[f"a{kk}"]
if mv in split:
warnings.append(f"annular vertex a{kk} of tread {d} cut twice; "
warnings.append(f"annular vertex a{kk} of tread {key} cut twice; "
f"second cut not applied")
continue
e_prev, e_next = (kk - 1) % n, kk
copy_a = (mv, "A", d)
copy_b = (mv, "B", d)
copy_a = (mv, "A", td)
copy_b = (mv, "B", td)
split[mv] = {
bij[f"a{(kk - 1) % n}"]: copy_a,
_apex_vertex(g, bij, e_prev): copy_a,
@@ -206,13 +232,14 @@ def _assemble_cut_graph(M, results, cap_cuts=None):
H.add_edge(resolve(u, v), resolve(v, u))
label_records = []
for d in sorted(results):
g, bij, depth = results[d]["g"], results[d]["bij"], results[d]["depth"]
for key in sorted(results):
td = key[0]
g, bij, depth = results[key]["g"], results[key]["bij"], results[key]["depth"]
for k in range(g.n):
role = ("up" if g.tooth_word[k] == "U"
else "bite" if door_bite(g, k) is not None else "down")
label_records.append({
"tread": d, "edge": k, "role": role,
"tread": td, "comp": key[1], "edge": k, "role": role,
"apex": _apex_vertex(g, bij, k), "walk": depth[k],
})
return H, label_records, warnings
@@ -278,11 +305,12 @@ def run_experiment(n: int = 12, seed: int = 0, flips: int = 400,
if len(tread["up"]) < 3:
skipped.append((d, f"only {len(tread['up'])} up teeth"))
continue
rec = recognise(medial_tire_facemodel(tread["tread_faces"]), tread)
if rec is None:
skipped.append((d, "not a valid full medial tire graph"))
tires = recognise(medial_tire_facemodel(tread["tread_faces"]), tread)
if not tires:
skipped.append((d, "no annular cycle recognised as a tire"))
continue
treads[d] = rec
for c, gb in enumerate(tires):
treads[(d, c)] = gb
results = {}
_label_treads(treads, results)
@@ -313,9 +341,10 @@ def _vname(v) -> str:
def to_json(result: dict) -> dict:
res = result["results"]
treads_out = []
for d in sorted(res):
g, bij = res[d]["g"], res[d]["bij"]
depth, cuts = res[d]["depth"], res[d]["cuts"]
for key in sorted(res):
d, comp = key
g, bij = res[key]["g"], res[key]["bij"]
depth, cuts = res[key]["depth"], res[key]["cuts"]
teeth = [{
"edge": k,
"role": ("up" if g.tooth_word[k] == "U"
@@ -324,9 +353,9 @@ def to_json(result: dict) -> dict:
"walk": depth[k],
} for k in range(g.n)]
treads_out.append({
"depth": d, "n": g.n, "tooth_word": g.tooth_word,
"depth": d, "comp": comp, "n": g.n, "tooth_word": g.tooth_word,
"bites": sorted(list(b) for b in g.bites),
"entry_edge": res[d]["entry_edge"], "start_depth": res[d]["start_depth"],
"entry_edge": res[key]["entry_edge"], "start_depth": res[key]["start_depth"],
"teeth": teeth,
"cuts": [{
"annular_index": c.vertex,
@@ -352,7 +381,8 @@ def to_json(result: dict) -> dict:
"edges": sorted([_vname(u), _vname(v)] for u, v in H.edges()),
},
"labels": [{
"tread": r["tread"], "edge": r["edge"], "role": r["role"],
"tread": r["tread"], "comp": r.get("comp", 0),
"edge": r["edge"], "role": r["role"],
"apex": _vname(r["apex"]), "walk": r["walk"],
} for r in result["labels"]],
"warnings": result["warnings"],
@@ -367,16 +397,17 @@ def summary(result: dict) -> str:
f"({result['G'].number_of_edges()} edges, min degree {result['min_degree']})",
f"medial graph M(G): {result['M'].number_of_nodes()} vertices",
f"level source: vertex {result['source']}",
f"recognised treads: {sorted(res)}",
f"recognised tires (depth, component): {sorted(res)}",
f"skipped treads: {result['skipped']}",
]
for d in sorted(res):
g = res[d]["g"]
ncuts = len(res[d]["cuts"])
for key in sorted(res):
d, comp = key
g = res[key]["g"]
ncuts = len(res[key]["cuts"])
lines.append(
f" tread {d}: |A(T)|={g.n} word={g.tooth_word} "
f"bites={sorted(g.bites)} entry=e{res[d]['entry_edge']} "
f"start_depth={res[d]['start_depth']} cuts={ncuts}")
f" tread {d}.{comp}: |A(T)|={g.n} word={g.tooth_word} "
f"bites={sorted(g.bites)} entry=e{res[key]['entry_edge']} "
f"start_depth={res[key]['start_depth']} cuts={ncuts}")
lines.append(
f"final cut graph: {H.number_of_nodes()} vertices, "
f"{H.number_of_edges()} edges, "
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

@@ -178,19 +178,17 @@ def extract_tread(faces, levels, d):
}
def annular_cycle_order(M: nx.Graph, annular: set):
"""Cyclic order of the annular medial vertices (they induce a cycle)."""
sub = M.subgraph(annular)
if sub.number_of_nodes() == 0 or any(sub.degree(v) != 2 for v in sub):
def _cycle_order(sub: nx.Graph, comp):
"""Cyclic order of a 2-regular connected component ``comp`` of ``sub``;
None if it is not a single simple cycle of length >= 3."""
csub = sub.subgraph(comp)
if csub.number_of_nodes() < 3 or any(csub.degree(v) != 2 for v in csub):
return None
if not nx.is_connected(sub):
return None
start = next(iter(annular))
start = next(iter(comp))
order = [start]
prev = None
cur = start
prev, cur = None, start
while True:
nbrs = [w for w in sub.neighbors(cur) if w != prev]
nbrs = [w for w in csub.neighbors(cur) if w != prev]
if not nbrs:
break
nxt = nbrs[0]
@@ -198,7 +196,33 @@ def annular_cycle_order(M: nx.Graph, annular: set):
break
order.append(nxt)
prev, cur = cur, nxt
return order if len(order) == len(annular) else None
return order if len(order) == csub.number_of_nodes() else None
def annular_cycle_order(M: nx.Graph, annular: set):
"""Cyclic order of the annular medial vertices when they induce a *single*
cycle; None otherwise. See ``annular_cycle_components`` for the
multi-component case."""
sub = M.subgraph(annular)
if not annular or not nx.is_connected(sub):
return None
return _cycle_order(sub, set(annular))
def annular_cycle_components(M: nx.Graph, annular: set):
"""Cyclic orders of the annular medial vertices, one per connected
component of the annular subgraph.
A tread's annular frontier may split into several disjoint cycles (one per
boundary component); each is its own full medial tire graph. Components
that are not a single simple cycle of length >= 3 are skipped."""
sub = M.subgraph(annular)
orders = []
for comp in nx.connected_components(sub):
order = _cycle_order(sub, comp)
if order is not None:
orders.append(order)
return orders
# --------------------------------------------------------------------------- #
@@ -226,38 +250,35 @@ def _linear_cut(n, bite_pairs):
return None
def recognise(M, tread):
"""Return (FullMedialTireGraph, bijection fmt-name -> medial vertex) or None.
def _recognise_one(M, order, up, ann_global):
"""Recognise a single annular cycle (given as the cyclic order of its
medial vertices) as a ``FullMedialTireGraph``.
``M`` here is the tread-face model M(T) (cycle + teeth + bites)."""
annular = tread["annular"]
order = annular_cycle_order(M, annular)
if order is None or len(order) < 3:
return None
``up`` is the tread's up-edge medial-vertex set; ``ann_global`` is the full
annular set of the tread (used to exclude annular vertices, including those
of *other* components, when picking each cycle edge's apex). Returns
``(g, bij)`` or None."""
n = len(order)
ann_set = set(annular)
if n < 3:
return None
ann_set = set(order)
apex_of_edge = []
for i in range(n):
a, b = order[i], order[(i + 1) % n]
common = [w for w in set(M.neighbors(a)) & set(M.neighbors(b)) if w not in ann_set]
common = [w for w in set(M.neighbors(a)) & set(M.neighbors(b))
if w not in ann_global]
if len(common) != 1:
return None
apex_of_edge.append(common[0])
up = set(tread["up"])
# bite apex: serves two cycle edges (== adjacent to four annular vertices)
apex_positions = defaultdict(list)
for i, ap in enumerate(apex_of_edge):
apex_positions[ap].append(i)
tooth = []
bite_pairs = []
for ap, positions in apex_positions.items():
if len(positions) == 2:
bite_pairs.append(tuple(sorted(positions)))
for i, ap in enumerate(apex_of_edge):
tooth.append("U" if ap in up else "D")
bite_pairs = [tuple(sorted(positions))
for positions in apex_positions.values() if len(positions) == 2]
tooth = ["U" if ap in up else "D" for ap in apex_of_edge]
cut = _linear_cut(n, bite_pairs)
if cut is None:
@@ -279,14 +300,35 @@ def recognise(M, tread):
for (i, j) in sorted(g.bites):
bij[f"p{i}_{j}"] = apex_of_edge[(i + r) % n]
# verify the reconstructed graph is edge-faithful to the tread-face M(T)
mt_edges = {ekey(*e) for e in M.edges()}
# verify the reconstructed graph is edge-faithful to this cycle's sub-model
# (its annular vertices together with their tooth apexes).
sub_nodes = ann_set | set(apex_of_edge)
sub_edges = {ekey(*e) for e in M.subgraph(sub_nodes).edges()}
rec_edges = {ekey(bij[u], bij[v]) for u, v in g.edges()}
if rec_edges != mt_edges:
if rec_edges != sub_edges:
return None
return g, bij
def recognise(M, tread):
"""Recognise the tread's medial-tire structure.
A tread's annular frontier may be several disjoint cycles, each its own
full medial tire graph. Returns a list of ``(FullMedialTireGraph,
bijection fmt-name -> medial vertex)`` -- one per annular cycle component
that recognises -- or ``[]`` if none do.
``M`` here is the tread-face model M(T) (cycle(s) + teeth + bites)."""
up = set(tread["up"])
ann_global = set(tread["annular"])
tires = []
for order in annular_cycle_components(M, tread["annular"]):
rec = _recognise_one(M, order, up, ann_global)
if rec is not None:
tires.append(rec)
return tires
def canonical(coloring, ordered):
remap, out = {}, []
for v in ordered:
@@ -341,32 +383,29 @@ def iter_pieces(seed: int, color_limit: int = 400000):
if tread is None or len(tread["up"]) < 3:
continue
mt = medial_tire_facemodel(tread["tread_faces"])
rec = recognise(mt, tread)
if rec is None:
continue
g, bij = rec
mt_nodes = list(bij.values())
name_of = {v: k for k, v in bij.items()}
for comp, (g, bij) in enumerate(recognise(mt, tread)):
mt_nodes = list(bij.values())
name_of = {v: k for k, v in bij.items()}
realized = set()
for col in global_colorings:
realized.add(canonical({v: col[v] for v in mt_nodes}, mt_nodes))
realized = set()
for col in global_colorings:
realized.add(canonical({v: col[v] for v in mt_nodes}, mt_nodes))
colorings = []
seen = set()
for col in proper_3_colorings_subgraph(mt, mt_nodes):
key = canonical(col, mt_nodes)
if key in seen:
continue
seen.add(key)
fmt_col = {name_of[v]: c for v, c in col.items()}
balanced = kempe_classify(g, fmt_col).valid
is_real = key in realized
cat = ("Invalid" if not balanced
else "Realized" if is_real else "Unrealized")
colorings.append((fmt_col, cat))
meta = {"source": s, "tread": d}
yield (meta, g, colorings)
colorings = []
seen = set()
for col in proper_3_colorings_subgraph(mt, mt_nodes):
key = canonical(col, mt_nodes)
if key in seen:
continue
seen.add(key)
fmt_col = {name_of[v]: c for v, c in col.items()}
balanced = kempe_classify(g, fmt_col).valid
is_real = key in realized
cat = ("Invalid" if not balanced
else "Realized" if is_real else "Unrealized")
colorings.append((fmt_col, cat))
meta = {"source": s, "tread": d, "comp": comp}
yield (meta, g, colorings)
def analyse(seed: int, color_limit: int = 400000):