Stop splitting random medial treads into components

This commit is contained in:
2026-06-15 14:41:41 -04:00
parent 464335082d
commit 51c9efa7f2
10 changed files with 129 additions and 93 deletions
@@ -10,16 +10,9 @@
- tire-tree nodes: 4
- tire-tree edges: 3
| node | depth | faces | annular | up | singleton down | bite apexes | full medial tires |
| node | depth | faces | annular cycles | annular | up | singleton down | bite apexes |
|--:|--:|--:|--:|--:|--:|--:|--:|
| T0 | 2 | 23 | 23 | 10 | 13 | 0 | 1 |
| T0.0 | | | | 10 | 13 | - | `UDDUDUUDDUDUDUDUDDDUUDD` |
| T1 | 1 | 15 | 15 | 5 | 10 | 0 | 1 |
| T1.0 | | | | 5 | 10 | - | `UDDDUDDUDUDDDUD` |
| T2 | 3 | 14 | 14 | 11 | 0 | 0 | 4 |
| T2.0 | | | | 3 | 0 | - | `UUU` |
| T2.1 | | | | 3 | 0 | - | `UUU` |
| T2.2 | | | | 5 | 0 | - | `UUUUU` |
| T2.3 | | | | 3 | 0 | - | `UUU` |
| T3 | 3 | 5 | 5 | 5 | 0 | 0 | 1 |
| T3.0 | | | | 5 | 0 | - | `UUUUU` |
| T0 | 2 | 23 | 1 | 23 | 10 | 13 | 0 |
| T1 | 1 | 15 | 1 | 15 | 5 | 10 | 0 |
| T2 | 3 | 14 | 4 | 14 | 11 | 0 | 0 |
| T3 | 3 | 5 | 1 | 5 | 5 | 0 | 0 |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 KiB

After

Width:  |  Height:  |  Size: 394 KiB

@@ -2,9 +2,8 @@
The source graphs come from ``plantri -c5`` in graph6 format. For each sampled
30-vertex triangulation, this script chooses a random source vertex, builds the
BFS depth-component tire tree, recognizes every full medial tire graph in the
decomposition, and draws both the tire tree and the realized full medial tire
graphs.
BFS depth-component tire tree, and draws both the tire tree and the medial
tread model for each depth component.
"""
from __future__ import annotations
@@ -36,8 +35,11 @@ import networkx as nx
if str(PAPER_DIR) not in sys.path:
sys.path.insert(0, str(PAPER_DIR))
from lib.medial_tire_decomposition import ekey, medial_tire_facemodel, recognise
from lib.full_medial_tire_generator import FullMedialTireGraph
from lib.medial_tire_decomposition import (
annular_cycle_components,
ekey,
medial_tire_facemodel,
)
@dataclass(frozen=True)
@@ -49,7 +51,8 @@ class TreadNode:
up: frozenset
down: frozenset
bites: frozenset
tires: tuple[tuple[FullMedialTireGraph, dict], ...]
medial: nx.Graph
annular_cycles: tuple[tuple, ...]
@dataclass(frozen=True)
@@ -228,8 +231,8 @@ def build_tire_tree(g: nx.Graph, source: int, augment: bool = True):
if tread is None or len(tread["up"]) < 3:
continue
mt = medial_tire_facemodel(tread["tread_faces"])
tires = tuple(recognise(mt, tread))
if not tires:
annular_cycles = tuple(annular_cycle_components(mt, tread["annular"]))
if not annular_cycles:
continue
node = TreadNode(
idx=len(nodes),
@@ -239,7 +242,8 @@ def build_tire_tree(g: nx.Graph, source: int, augment: bool = True):
up=frozenset(tread["up"]),
down=frozenset(tread["down"]),
bites=frozenset(tread["bites"]),
tires=tires,
medial=mt,
annular_cycles=annular_cycles,
)
comp_to_node[comp_idx] = node.idx
nodes.append(node)
@@ -343,7 +347,7 @@ def draw_tire_tree(ax, nodes: list[TreadNode], tree_edges):
ax.text(
x,
y,
f"T{node.idx}\nd={node.depth}\n{len(node.tires)} tire(s)",
f"T{node.idx}\nd={node.depth}\n{len(node.annular_cycles)} cycle(s)",
ha="center",
va="center",
fontsize=8,
@@ -373,58 +377,113 @@ def edge_midpoint_angle(i: int, n: int) -> float:
return math.pi / 2 - 2 * math.pi * (i + 0.5) / n
def draw_full_medial_tire(ax, graph: FullMedialTireGraph, title: str):
n = graph.n
ann = [vertex_xy(k, n, 1.0) for k in range(n)]
matched = graph.bite_edges
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.3, zorder=2)
for i, tooth in enumerate(graph.tooth_word):
if tooth == "U":
r, color = 1.42, "#2563eb"
elif i not in matched:
r, color = 0.58, "#dc2626"
else:
continue
ang = edge_midpoint_angle(i, n)
apex = (r * math.cos(ang), r * math.sin(ang))
for corner in (ann[i], ann[(i + 1) % n]):
ax.plot([apex[0], corner[0]], [apex[1], corner[1]], color="#9ca3af", lw=0.5)
ax.scatter([apex[0]], [apex[1]], s=12, color=color, zorder=3)
for i, j in sorted(graph.bites):
corners = [ann[i], ann[(i + 1) % n], ann[j], ann[(j + 1) % n]]
apex = (0.82 * sum(p[0] for p in corners) / 4, 0.82 * sum(p[1] for p in corners) / 4)
def draw_tread_model(ax, node: TreadNode):
cycle_count = len(node.annular_cycles)
offsets = [3.25 * (i - (cycle_count - 1) / 2) for i in range(cycle_count)]
apex_positions: dict[tuple, tuple[float, float]] = {}
apex_corners: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
ann_positions: dict[tuple, tuple[float, float]] = {}
for cycle_idx, order in enumerate(node.annular_cycles):
n = len(order)
dx = offsets[cycle_idx]
ann = {
vertex: (dx + x, y)
for vertex, (x, y) in zip(order, [vertex_xy(k, n, 1.0) for k in range(n)])
}
ann_positions.update(ann)
cyc_x = [ann[v][0] for v in order] + [ann[order[0]][0]]
cyc_y = [ann[v][1] for v in order] + [ann[order[0]][1]]
ax.plot(cyc_x, cyc_y, color="black", lw=1.3, zorder=2)
for i, a in enumerate(order):
b = order[(i + 1) % n]
apexes = [
w for w in set(node.medial.neighbors(a)) & set(node.medial.neighbors(b))
if w not in node.annular
]
for apex in apexes:
apex_corners[apex].extend([ann[a], ann[b]])
if apex in apex_positions:
continue
angle = edge_midpoint_angle(i, n)
if apex in node.up:
radius = 1.42
else:
radius = 0.58
apex_positions[apex] = (
dx + radius * math.cos(angle),
radius * math.sin(angle),
)
for apex, corners in apex_corners.items():
if apex in node.bites and corners:
cx = sum(p[0] for p in corners) / len(corners)
cy = sum(p[1] for p in corners) / len(corners)
center_x = sum(offsets) / len(offsets) if offsets else 0.0
apex_positions[apex] = (
center_x + 0.82 * (cx - center_x),
0.82 * cy,
)
pos = apex_positions[apex]
for corner in corners:
ax.plot([apex[0], corner[0]], [apex[1], corner[1]], color="#9ca3af", lw=0.5)
ax.scatter([apex[0]], [apex[1]], s=22, color="#7f1d1d", edgecolors="black", lw=0.4)
ax.scatter([p[0] for p in ann], [p[1] for p in ann], s=9, color="black", zorder=4)
bites = ",".join(f"{i}{j}" for i, j in sorted(graph.bites)) or "-"
ax.set_title(f"{title}\n{graph.tooth_word} b:{bites}", fontsize=5.8, pad=1.5)
ax.set_xlim(-1.6, 1.6)
ax.set_ylim(-1.6, 1.6)
ax.plot([pos[0], corner[0]], [pos[1], corner[1]], color="#9ca3af", lw=0.5)
for apex, pos in apex_positions.items():
if apex in node.up:
color, size, edgecolor = "#2563eb", 13, "none"
elif apex in node.bites:
color, size, edgecolor = "#7f1d1d", 24, "black"
else:
color, size, edgecolor = "#dc2626", 13, "none"
ax.scatter(
[pos[0]],
[pos[1]],
s=size,
color=color,
edgecolors=edgecolor,
linewidths=0.4,
zorder=3,
)
if ann_positions:
ax.scatter(
[p[0] for p in ann_positions.values()],
[p[1] for p in ann_positions.values()],
s=9,
color="black",
zorder=4,
)
singleton_down = set(node.down) - set(node.bites)
ax.set_title(
f"T{node.idx} d={node.depth}: {len(node.annular_cycles)} annular cycle(s)\n"
f"ann={len(node.annular)} up={len(node.up)} down={len(singleton_down)} "
f"bite={len(node.bites)}",
fontsize=6.4,
pad=1.5,
)
pad = 1.7
ax.set_xlim(min(offsets, default=0.0) - pad, max(offsets, default=0.0) + pad)
ax.set_ylim(-1.65, 1.65)
ax.set_aspect("equal")
ax.axis("off")
def draw_medial_tire_grid(fig, outer_spec, nodes):
tires = []
for node in nodes:
for comp_idx, (graph, _bij) in enumerate(node.tires):
tires.append((node.idx, comp_idx, graph))
if not tires:
if not nodes:
ax = fig.add_subplot(outer_spec)
ax.text(0.5, 0.5, "No full medial tire graphs recognized", ha="center")
ax.text(0.5, 0.5, "No medial treads extracted", ha="center")
ax.axis("off")
return
cols = min(5, max(1, math.ceil(math.sqrt(len(tires)))))
rows = math.ceil(len(tires) / cols)
cols = min(3, max(1, math.ceil(math.sqrt(len(nodes)))))
rows = math.ceil(len(nodes) / cols)
sub = outer_spec.subgridspec(rows, cols, wspace=0.08, hspace=0.35)
for i in range(rows * cols):
ax = fig.add_subplot(sub[i // cols, i % cols])
if i < len(tires):
node_idx, comp_idx, graph = tires[i]
draw_full_medial_tire(ax, graph, f"T{node_idx}.{comp_idx} n={graph.n}")
if i < len(nodes):
draw_tread_model(ax, nodes[i])
else:
ax.axis("off")
@@ -452,22 +511,16 @@ def write_index(
f"- tire-tree nodes: {len(nodes)}",
f"- tire-tree edges: {len(tree_edges)}",
"",
"| node | depth | faces | annular | up | singleton down | bite apexes | full medial tires |",
"| node | depth | faces | annular cycles | annular | up | singleton down | bite apexes |",
"|--:|--:|--:|--:|--:|--:|--:|--:|",
]
for node in nodes:
singleton_down = set(node.down) - set(node.bites)
lines.append(
f"| T{node.idx} | {node.depth} | {len(node.face_indices)} | "
f"{len(node.annular)} | {len(node.up)} | {len(singleton_down)} | "
f"{len(node.bites)} | {len(node.tires)} |"
f"{len(node.annular_cycles)} | {len(node.annular)} | {len(node.up)} | "
f"{len(singleton_down)} | {len(node.bites)} |"
)
for comp_idx, (tire, _bij) in enumerate(node.tires):
bites = ",".join(f"({i},{j})" for i, j in sorted(tire.bites)) or "-"
lines.append(
f"| T{node.idx}.{comp_idx} | | | | {len(tire.up_edges)} | "
f"{len(tire.singleton_down_edges)} | {bites} | `{tire.tooth_word}` |"
)
path.write_text("\n".join(lines) + "\n")
@@ -517,7 +570,7 @@ def draw_case(out_dir: Path, graph_idx: int, g: nx.Graph, source: int, augment:
nodes,
tree_edges,
)
return png, pdf, len(nodes), sum(len(node.tires) for node in nodes)
return png, pdf, len(nodes), sum(len(node.annular_cycles) for node in nodes)
def run(args: argparse.Namespace):
@@ -535,12 +588,12 @@ def run(args: argparse.Namespace):
sources = [rng.choice(list(graph.nodes())) for graph in graphs]
for i, (graph, source) in enumerate(zip(graphs, sources), start=1):
png, pdf, node_count, tire_count = draw_case(
png, pdf, node_count, annular_cycle_count = draw_case(
out_dir, i, graph, source, augment=not args.no_augment_same_level_faces
)
print(
f"case {i}: source={source}, connectivity={nx.node_connectivity(graph)}, "
f"tire nodes={node_count}, full medial tires={tire_count}"
f"tire nodes={node_count}, annular cycles={annular_cycle_count}"
)
print(f" wrote {png}")
print(f" wrote {pdf}")
@@ -10,13 +10,8 @@
- tire-tree nodes: 3
- tire-tree edges: 2
| node | depth | faces | annular | up | singleton down | bite apexes | full medial tires |
| node | depth | faces | annular cycles | annular | up | singleton down | bite apexes |
|--:|--:|--:|--:|--:|--:|--:|--:|
| T0 | 1 | 16 | 16 | 6 | 10 | 0 | 1 |
| T0.0 | | | | 6 | 10 | - | `DUDUDUDDUDDDUDDU` |
| T1 | 2 | 20 | 20 | 10 | 10 | 0 | 1 |
| T1.0 | | | | 10 | 10 | - | `UUDUUDUDDUDUDUDUUDDD` |
| T2 | 3 | 16 | 16 | 12 | 0 | 1 | 3 |
| T2.0 | | | | 6 | 0 | (1,5) | `UDUUUDUU` |
| T2.1 | | | | 3 | 0 | - | `UUU` |
| T2.2 | | | | 5 | 0 | - | `UUUUU` |
| T0 | 1 | 16 | 1 | 16 | 6 | 10 | 0 |
| T1 | 2 | 20 | 1 | 20 | 10 | 10 | 0 |
| T2 | 3 | 16 | 3 | 16 | 12 | 0 | 1 |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 340 KiB

@@ -10,14 +10,9 @@
- tire-tree nodes: 4
- tire-tree edges: 3
| node | depth | faces | annular | up | singleton down | bite apexes | full medial tires |
| node | depth | faces | annular cycles | annular | up | singleton down | bite apexes |
|--:|--:|--:|--:|--:|--:|--:|--:|
| T0 | 1 | 16 | 16 | 7 | 9 | 0 | 1 |
| T0.0 | | | | 7 | 9 | - | `DUDUDUDUDUDDUDUD` |
| T1 | 2 | 17 | 17 | 9 | 8 | 0 | 1 |
| T1.0 | | | | 9 | 8 | - | `UUUDDUDUDUDUDDUUD` |
| T2 | 3 | 14 | 14 | 8 | 4 | 1 | 1 |
| T2.0 | | | | 8 | 4 | (1,5) | `DDUUUDDUUDUDUU` |
| T3 | 4 | 6 | 6 | 5 | 0 | 0 | 2 |
| T3.0 | | | | 3 | 0 | - | `UUU` |
| T3.1 | | | | 3 | 0 | - | `UUU` |
| T0 | 1 | 16 | 1 | 16 | 7 | 9 | 0 |
| T1 | 2 | 17 | 1 | 17 | 9 | 8 | 0 |
| T2 | 3 | 14 | 1 | 14 | 8 | 4 | 1 |
| T3 | 4 | 6 | 2 | 6 | 5 | 0 | 0 |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

After

Width:  |  Height:  |  Size: 386 KiB