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 nodes: 4
- tire-tree edges: 3 - 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 | 2 | 23 | 1 | 23 | 10 | 13 | 0 |
| T0.0 | | | | 10 | 13 | - | `UDDUDUUDDUDUDUDUDDDUUDD` | | T1 | 1 | 15 | 1 | 15 | 5 | 10 | 0 |
| T1 | 1 | 15 | 15 | 5 | 10 | 0 | 1 | | T2 | 3 | 14 | 4 | 14 | 11 | 0 | 0 |
| T1.0 | | | | 5 | 10 | - | `UDDDUDDUDUDDDUD` | | T3 | 3 | 5 | 1 | 5 | 5 | 0 | 0 |
| 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` |
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 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 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 BFS depth-component tire tree, and draws both the tire tree and the medial
decomposition, and draws both the tire tree and the realized full medial tire tread model for each depth component.
graphs.
""" """
from __future__ import annotations from __future__ import annotations
@@ -36,8 +35,11 @@ import networkx as nx
if str(PAPER_DIR) not in sys.path: if str(PAPER_DIR) not in sys.path:
sys.path.insert(0, str(PAPER_DIR)) sys.path.insert(0, str(PAPER_DIR))
from lib.medial_tire_decomposition import ekey, medial_tire_facemodel, recognise from lib.medial_tire_decomposition import (
from lib.full_medial_tire_generator import FullMedialTireGraph annular_cycle_components,
ekey,
medial_tire_facemodel,
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -49,7 +51,8 @@ class TreadNode:
up: frozenset up: frozenset
down: frozenset down: frozenset
bites: frozenset bites: frozenset
tires: tuple[tuple[FullMedialTireGraph, dict], ...] medial: nx.Graph
annular_cycles: tuple[tuple, ...]
@dataclass(frozen=True) @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: if tread is None or len(tread["up"]) < 3:
continue continue
mt = medial_tire_facemodel(tread["tread_faces"]) mt = medial_tire_facemodel(tread["tread_faces"])
tires = tuple(recognise(mt, tread)) annular_cycles = tuple(annular_cycle_components(mt, tread["annular"]))
if not tires: if not annular_cycles:
continue continue
node = TreadNode( node = TreadNode(
idx=len(nodes), idx=len(nodes),
@@ -239,7 +242,8 @@ def build_tire_tree(g: nx.Graph, source: int, augment: bool = True):
up=frozenset(tread["up"]), up=frozenset(tread["up"]),
down=frozenset(tread["down"]), down=frozenset(tread["down"]),
bites=frozenset(tread["bites"]), bites=frozenset(tread["bites"]),
tires=tires, medial=mt,
annular_cycles=annular_cycles,
) )
comp_to_node[comp_idx] = node.idx comp_to_node[comp_idx] = node.idx
nodes.append(node) nodes.append(node)
@@ -343,7 +347,7 @@ def draw_tire_tree(ax, nodes: list[TreadNode], tree_edges):
ax.text( ax.text(
x, x,
y, 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", ha="center",
va="center", va="center",
fontsize=8, 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 return math.pi / 2 - 2 * math.pi * (i + 0.5) / n
def draw_full_medial_tire(ax, graph: FullMedialTireGraph, title: str): def draw_tread_model(ax, node: TreadNode):
n = graph.n cycle_count = len(node.annular_cycles)
ann = [vertex_xy(k, n, 1.0) for k in range(n)] offsets = [3.25 * (i - (cycle_count - 1) / 2) for i in range(cycle_count)]
matched = graph.bite_edges apex_positions: dict[tuple, tuple[float, float]] = {}
cyc_x = [p[0] for p in ann] + [ann[0][0]] apex_corners: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
cyc_y = [p[1] for p in ann] + [ann[0][1]] 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) ax.plot(cyc_x, cyc_y, color="black", lw=1.3, zorder=2)
for i, tooth in enumerate(graph.tooth_word):
if tooth == "U": for i, a in enumerate(order):
r, color = 1.42, "#2563eb" b = order[(i + 1) % n]
elif i not in matched: apexes = [
r, color = 0.58, "#dc2626" w for w in set(node.medial.neighbors(a)) & set(node.medial.neighbors(b))
else: if w not in node.annular
]
for apex in apexes:
apex_corners[apex].extend([ann[a], ann[b]])
if apex in apex_positions:
continue continue
ang = edge_midpoint_angle(i, n) angle = edge_midpoint_angle(i, n)
apex = (r * math.cos(ang), r * math.sin(ang)) if apex in node.up:
for corner in (ann[i], ann[(i + 1) % n]): radius = 1.42
ax.plot([apex[0], corner[0]], [apex[1], corner[1]], color="#9ca3af", lw=0.5) else:
ax.scatter([apex[0]], [apex[1]], s=12, color=color, zorder=3) radius = 0.58
for i, j in sorted(graph.bites): apex_positions[apex] = (
corners = [ann[i], ann[(i + 1) % n], ann[j], ann[(j + 1) % n]] dx + radius * math.cos(angle),
apex = (0.82 * sum(p[0] for p in corners) / 4, 0.82 * sum(p[1] for p in corners) / 4) 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: for corner in corners:
ax.plot([apex[0], corner[0]], [apex[1], corner[1]], color="#9ca3af", lw=0.5) ax.plot([pos[0], corner[0]], [pos[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) for apex, pos in apex_positions.items():
bites = ",".join(f"{i}{j}" for i, j in sorted(graph.bites)) or "-" if apex in node.up:
ax.set_title(f"{title}\n{graph.tooth_word} b:{bites}", fontsize=5.8, pad=1.5) color, size, edgecolor = "#2563eb", 13, "none"
ax.set_xlim(-1.6, 1.6) elif apex in node.bites:
ax.set_ylim(-1.6, 1.6) 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.set_aspect("equal")
ax.axis("off") ax.axis("off")
def draw_medial_tire_grid(fig, outer_spec, nodes): def draw_medial_tire_grid(fig, outer_spec, nodes):
tires = [] if not nodes:
for node in nodes:
for comp_idx, (graph, _bij) in enumerate(node.tires):
tires.append((node.idx, comp_idx, graph))
if not tires:
ax = fig.add_subplot(outer_spec) 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") ax.axis("off")
return return
cols = min(5, max(1, math.ceil(math.sqrt(len(tires))))) cols = min(3, max(1, math.ceil(math.sqrt(len(nodes)))))
rows = math.ceil(len(tires) / cols) rows = math.ceil(len(nodes) / cols)
sub = outer_spec.subgridspec(rows, cols, wspace=0.08, hspace=0.35) sub = outer_spec.subgridspec(rows, cols, wspace=0.08, hspace=0.35)
for i in range(rows * cols): for i in range(rows * cols):
ax = fig.add_subplot(sub[i // cols, i % cols]) ax = fig.add_subplot(sub[i // cols, i % cols])
if i < len(tires): if i < len(nodes):
node_idx, comp_idx, graph = tires[i] draw_tread_model(ax, nodes[i])
draw_full_medial_tire(ax, graph, f"T{node_idx}.{comp_idx} n={graph.n}")
else: else:
ax.axis("off") ax.axis("off")
@@ -452,21 +511,15 @@ def write_index(
f"- tire-tree nodes: {len(nodes)}", f"- tire-tree nodes: {len(nodes)}",
f"- tire-tree edges: {len(tree_edges)}", 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: for node in nodes:
singleton_down = set(node.down) - set(node.bites) singleton_down = set(node.down) - set(node.bites)
lines.append( lines.append(
f"| T{node.idx} | {node.depth} | {len(node.face_indices)} | " f"| T{node.idx} | {node.depth} | {len(node.face_indices)} | "
f"{len(node.annular)} | {len(node.up)} | {len(singleton_down)} | " f"{len(node.annular_cycles)} | {len(node.annular)} | {len(node.up)} | "
f"{len(node.bites)} | {len(node.tires)} |" 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") 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, nodes,
tree_edges, 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): def run(args: argparse.Namespace):
@@ -535,12 +588,12 @@ def run(args: argparse.Namespace):
sources = [rng.choice(list(graph.nodes())) for graph in graphs] sources = [rng.choice(list(graph.nodes())) for graph in graphs]
for i, (graph, source) in enumerate(zip(graphs, sources), start=1): 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 out_dir, i, graph, source, augment=not args.no_augment_same_level_faces
) )
print( print(
f"case {i}: source={source}, connectivity={nx.node_connectivity(graph)}, " 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 {png}")
print(f" wrote {pdf}") print(f" wrote {pdf}")
@@ -10,13 +10,8 @@
- tire-tree nodes: 3 - tire-tree nodes: 3
- tire-tree edges: 2 - 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 | 1 | 16 | 1 | 16 | 6 | 10 | 0 |
| T0.0 | | | | 6 | 10 | - | `DUDUDUDDUDDDUDDU` | | T1 | 2 | 20 | 1 | 20 | 10 | 10 | 0 |
| T1 | 2 | 20 | 20 | 10 | 10 | 0 | 1 | | T2 | 3 | 16 | 3 | 16 | 12 | 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` |
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 nodes: 4
- tire-tree edges: 3 - 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 | 1 | 16 | 1 | 16 | 7 | 9 | 0 |
| T0.0 | | | | 7 | 9 | - | `DUDUDUDUDUDDUDUD` | | T1 | 2 | 17 | 1 | 17 | 9 | 8 | 0 |
| T1 | 2 | 17 | 17 | 9 | 8 | 0 | 1 | | T2 | 3 | 14 | 1 | 14 | 8 | 4 | 1 |
| T1.0 | | | | 9 | 8 | - | `UUUDDUDUDUDUDDUUD` | | T3 | 4 | 6 | 2 | 6 | 5 | 0 | 0 |
| 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` |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

After

Width:  |  Height:  |  Size: 386 KiB