Doubly connected edge list in Python
Half-edge representation of a planar subdivision, supporting face and edge traversal.
- File
doubly_connected_edge_list.py, 227 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/doubly-connected-edge-list/. The authors are listed in prov/69ZYTE.jsonl.
"""A doubly connected edge list (half-edge structure) for a connected plane graph.
The embedding is given combinatorially, as a rotation system: for each vertex
the list of its neighbours in counterclockwise order. No coordinates are used.
Half-edges are numbered so that the twin of half-edge h is h ^ 1.
"""
class DCEL:
"""Half-edge structure of a connected plane graph (no loops, no parallel edges)."""
def __init__(self) -> None:
"""Make the plane graph with one vertex, no edges and one face."""
self.origin: list[int] = [] # origin[h]: the vertex where h starts
self.next: list[int] = [] # next[h]: the half-edge after h on its face
self.prev: list[int] = [] # prev[h]: the half-edge before h on its face
self.face: list[int] = [] # face[h]: the face to the left of h
self.vertex_edge: list[int] = [-1] # one half-edge out of each vertex, or -1
self.face_edge: list[int] = [-1] # one half-edge of each face, -1 for no edge, -2 for deleted
# ------------------------------------------------------------------ build
@classmethod
def from_rotation(cls, rot: list[list[int]]) -> "DCEL":
"""Build the structure from a rotation system of a connected graph.
rot[u] lists the neighbours of u in counterclockwise order. Time and
space O(n + m).
"""
d = cls()
n = len(rot)
d.vertex_edge = [-1] * n
d.face_edge = []
half = {} # (u, v) -> half-edge id
for u in range(n): # @line L1
for v in rot[u]:
if (u, v) in half:
continue
h = len(d.origin)
half[(u, v)], half[(v, u)] = h, h ^ 1 # @line L2
d.origin += [u, v]
d.vertex_edge[u] = h
d.vertex_edge[v] = h ^ 1
m2 = len(d.origin)
d.next, d.prev, d.face = [-1] * m2, [-1] * m2, [-1] * m2
pos = [{v: i for i, v in enumerate(rot[u])} for u in range(n)]
for (u, v), h in half.items(): # @line L3
r = rot[v]
w = r[(pos[v][u] - 1) % len(r)] # the neighbour before u around v
nh = half[(v, w)]
d.next[h] = nh # @line L4
d.prev[nh] = h
for h in range(m2): # @line L5
if d.face[h] != -1:
continue
f = len(d.face_edge)
d.face_edge.append(h)
g = h
while d.face[g] == -1: # @line L6
d.face[g] = f
g = d.next[g]
if n == 1 and m2 == 0:
d.face_edge = [-1]
return d
# ------------------------------------------------------------- queries
def twin(self, h: int) -> int:
"""The opposite half-edge of h."""
return h ^ 1
def num_vertices(self) -> int:
return len(self.vertex_edge)
def num_edges(self) -> int:
return len(self.origin) // 2
def faces(self) -> list[int]:
"""The ids of the faces that exist."""
return [f for f, e in enumerate(self.face_edge) if e != -2]
def face_boundary(self, f: int) -> list[int]:
"""The half-edges of face f in order. Time O(length of the boundary)."""
h0 = self.face_edge[f] # @line L9
if h0 < 0:
return []
out, h = [], h0
while True:
out.append(h)
h = self.next[h]
if h == h0:
return out
def neighbors(self, v: int) -> list[int]:
"""The neighbours of v in counterclockwise order. Time O(degree)."""
h0 = self.vertex_edge[v] # @line L7
if h0 < 0:
return []
out, h = [], h0
while True:
out.append(self.origin[h ^ 1])
# the next half-edge out of v, counterclockwise
h = self.prev[h] ^ 1 # @line L8
if h == h0:
return out
# ----------------------------------------------------------- updates
def _new_pair(self, u: int, v: int) -> int:
h = len(self.origin)
self.origin += [u, v]
self.next += [-1, -1]
self.prev += [-1, -1]
self.face += [-1, -1]
return h
def _link(self, a: int, b: int) -> None:
self.next[a] = b
self.prev[b] = a
def add_leaf(self, a: int) -> int:
"""Add a new vertex w and the edge from u = origin[a] to w, in the face of a.
If the graph has no edge, a must be -1 and u is vertex 0. The new edge
comes just before a around the face. Returns the half-edge u -> w. O(1).
"""
w = len(self.vertex_edge)
self.vertex_edge.append(-1)
if a == -1:
u, f = 0, 0
h = self._new_pair(u, w)
self._link(h, h ^ 1)
self._link(h ^ 1, h)
else:
u, f = self.origin[a], self.face[a]
h = self._new_pair(u, w)
p = self.prev[a] # @line L13
self._link(p, h)
self._link(h, h ^ 1)
self._link(h ^ 1, a)
self.face[h] = self.face[h ^ 1] = f
self.face_edge[f] = h
self.vertex_edge[u] = h
self.vertex_edge[w] = h ^ 1
return h
def split_face(self, a: int, b: int) -> int:
"""Add the edge from origin[a] to origin[b] through the common face of a and b.
Precondition: a != b, face[a] == face[b], origin[a] != origin[b], and the
two vertices are not yet adjacent. The face is split in two. Returns the
new half-edge h from origin[a] to origin[b]; its face is the new face.
Time O(length of the new face).
"""
f = self.face[a]
u, v = self.origin[a], self.origin[b]
h = self._new_pair(u, v) # @line L10
t = h ^ 1
pa, pb = self.prev[a], self.prev[b]
self._link(pa, h) # @line L11
self._link(h, b)
self._link(pb, t)
self._link(t, a)
g = len(self.face_edge) # @line L12
self.face_edge.append(h)
self.face_edge[f] = t
x = h
while True:
self.face[x] = g
x = self.next[x]
if x == h:
break
self.face[t] = f
return h
def join_faces(self, h: int) -> None:
"""Delete the edge of h when its two sides are different faces; merge the faces.
The face of h ^ 1 survives. Time O(length of the face of h).
"""
t = h ^ 1
f, g = self.face[t], self.face[h]
if f == g:
raise ValueError("the two sides of the edge are the same face")
x = self.next[h] # @line L14
while x != h:
self.face[x] = f
x = self.next[x]
self._link(self.prev[h], self.next[t]) # @line L15
self._link(self.prev[t], self.next[h])
u, v = self.origin[h], self.origin[t]
if self.vertex_edge[u] == h:
self.vertex_edge[u] = self.prev[h] ^ 1
if self.vertex_edge[v] == t:
self.vertex_edge[v] = self.prev[t] ^ 1
self.face_edge[f] = self.next[t]
self.face_edge[g] = -2
# the two half-edges stay in the arrays but are unlinked
for y in (h, t):
self.next[y] = self.prev[y] = self.face[y] = -1
self.origin[y] = -1
# -------------------------------------------------------------- check
def check_invariant(self) -> None:
"""Raise AssertionError if a pointer invariant or Euler's formula fails."""
live = [h for h in range(len(self.origin)) if self.origin[h] != -1]
for h in live:
assert self.origin[h ^ 1] != -1
assert self.prev[self.next[h]] == h and self.next[self.prev[h]] == h
assert self.origin[self.next[h]] == self.origin[h ^ 1]
assert self.face[self.next[h]] == self.face[h]
assert self.face_edge[self.face[h]] >= 0
for v, h in enumerate(self.vertex_edge):
assert h == -1 or self.origin[h] == v
fs = self.faces()
seen = 0
for f in fs:
b = self.face_boundary(f)
assert all(self.face[x] == f for x in b)
seen += len(b)
assert seen == len(live)
n, m = self.num_vertices(), len(live) // 2
assert n - m + len(fs) == 2
test_doubly_connected_edge_list.py 259 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/doubly-connected-edge-list/. The authors are listed in prov/69ZYTE.jsonl.
import math
import random
from doubly_connected_edge_list import DCEL
# ---- independent oracles ----
def rotation_from_points(pts, edges):
"""Rotation system of a straight-line drawing: neighbours sorted by angle."""
adj = [[] for _ in pts]
for u, v in edges:
adj[u].append(v)
adj[v].append(u)
rot = []
for u, nb in enumerate(adj):
x, y = pts[u]
rot.append(sorted(nb, key=lambda v: math.atan2(pts[v][1] - y, pts[v][0] - x)))
return rot
def trace_faces(rot):
"""Face cycles of a rotation system as lists of darts (u, v), traced with dicts."""
seen = set()
out = []
for u in range(len(rot)):
for v in rot[u]:
if (u, v) in seen:
continue
cyc = []
a, b = u, v
while (a, b) not in seen:
seen.add((a, b))
cyc.append((a, b))
r = rot[b]
c = r[(r.index(a) - 1) % len(r)]
a, b = b, c
out.append(cyc)
return out
def canon(cycle):
"""A rotation-invariant key of a cyclic list."""
if not cycle:
return ()
k = min(range(len(cycle)), key=lambda i: cycle[i:] + cycle[:i])
return tuple(cycle[k:] + cycle[:k])
def dcel_faces(d):
out = []
for f in d.faces():
b = d.face_boundary(f)
out.append(canon([(d.origin[h], d.origin[h ^ 1]) for h in b]))
return sorted(out)
def oracle_faces(rot):
cycles = trace_faces(rot)
if not cycles: # a graph with no edge has one face with an empty boundary
return [()]
return sorted(canon(c) for c in cycles)
def same_cyclic(a, b):
return len(a) == len(b) and (not a or canon(list(a)) == canon(list(b)))
def orient(p, q, r):
return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
def segments_cross(p, q, r, s):
"""True when closed segments pq and rs meet at a point that is not a shared end."""
shared = {p, q} & {r, s}
if shared:
# two segments with a common end meet elsewhere only if collinear and overlapping
o = q if p in shared else p
t = s if r in shared else r
c = p if p in shared else q
return orient(c, o, t) == 0 and (o[0] - c[0]) * (t[0] - c[0]) + (o[1] - c[1]) * (t[1] - c[1]) > 0
d1, d2 = orient(p, q, r), orient(p, q, s)
d3, d4 = orient(r, s, p), orient(r, s, q)
if d1 * d2 < 0 and d3 * d4 < 0:
return True
def on(a, b, c):
return orient(a, b, c) == 0 and min(a[0], b[0]) <= c[0] <= max(a[0], b[0]) and min(a[1], b[1]) <= c[1] <= max(a[1], b[1])
return on(p, q, r) or on(p, q, s) or on(r, s, p) or on(r, s, q)
def connected(n, edges):
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for u, v in edges:
parent[find(u)] = find(v)
return len({find(x) for x in range(n)}) == 1
# ---- fixed cases ----
K4_POINTS = [(0, 0), (4, 0), (2, 3), (2, 1)]
K4_EDGES = [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]
def test_page_example_k4():
rot = rotation_from_points(K4_POINTS, K4_EDGES)
assert rot == [[1, 3, 2], [2, 3, 0], [0, 3, 1], [0, 1, 2]]
d = DCEL.from_rotation(rot)
d.check_invariant()
assert d.num_edges() == 6 and len(d.faces()) == 4
assert all(len(d.face_boundary(f)) == 3 for f in d.faces())
assert dcel_faces(d) == oracle_faces(rot)
for v in range(4):
assert same_cyclic(d.neighbors(v), rot[v])
# split no face of K4 is possible; delete edge 2-3 and add it back
h = next(x for x in range(12) if (d.origin[x], d.origin[x ^ 1]) == (3, 2))
d.join_faces(h)
d.check_invariant()
assert len(d.faces()) == 3
assert sorted(len(d.face_boundary(f)) for f in d.faces()) == [3, 3, 4]
def test_single_vertex():
d = DCEL.from_rotation([[]])
d.check_invariant()
assert d.faces() == [0] and d.face_boundary(0) == [] and d.neighbors(0) == []
d2 = DCEL()
d2.check_invariant()
h = d2.add_leaf(-1)
d2.check_invariant()
assert d2.face_boundary(0) == [h, h ^ 1]
def test_tree_has_one_face():
rot = [[1, 2, 3], [0], [0], [0, 4], [3]]
d = DCEL.from_rotation(rot)
d.check_invariant()
assert len(d.faces()) == 1
assert len(d.face_boundary(d.faces()[0])) == 8
def test_cube():
pts = [(0, 0), (6, 0), (6, 6), (0, 6), (2, 2), (4, 2), (4, 4), (2, 4)]
edges = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)]
rot = rotation_from_points(pts, edges)
d = DCEL.from_rotation(rot)
d.check_invariant()
assert len(d.faces()) == 6
assert all(len(d.face_boundary(f)) == 4 for f in d.faces())
def test_nonplanar_rotations_fail_euler():
k5 = [[v for v in range(5) if v != u] for u in range(5)]
k33 = [[3, 4, 5]] * 3 + [[0, 1, 2]] * 3
for rot in (k5, k33):
d = DCEL.from_rotation(rot)
assert dcel_faces(d) == oracle_faces(rot)
try:
d.check_invariant()
except AssertionError:
continue
raise AssertionError("a non-planar graph passed the Euler check")
def test_split_face_square():
rot = [[1, 3], [2, 0], [3, 1], [0, 2]]
d = DCEL.from_rotation(rot)
f = d.faces()[0]
b = d.face_boundary(f)
a = next(x for x in b if d.origin[x] == 0)
c = next(x for x in b if d.origin[x] == 2)
h = d.split_face(a, c)
d.check_invariant()
assert len(d.faces()) == 3
assert len(d.face_boundary(d.face[h])) == 3 and len(d.face_boundary(d.face[h ^ 1])) == 3
# ---- randomized ----
def random_geometric(rng):
n = rng.randint(1, 8)
pts = []
while len(pts) < n:
p = (rng.randint(0, 12), rng.randint(0, 12))
if p not in pts:
pts.append(p)
pairs = [(u, v) for u in range(n) for v in range(u + 1, n)]
rng.shuffle(pairs)
edges = []
for u, v in pairs:
if rng.random() < 0.3:
continue
if all(not segments_cross(pts[u], pts[v], pts[a], pts[b]) for a, b in edges):
edges.append((u, v))
return pts, edges
def test_random_straight_line_drawings():
rng = random.Random(1)
done = 0
while done < 600:
pts, edges = random_geometric(rng)
if not connected(len(pts), edges):
continue
rot = rotation_from_points(pts, edges)
d = DCEL.from_rotation(rot)
d.check_invariant()
assert dcel_faces(d) == oracle_faces(rot)
assert len(d.faces()) == len(edges) - len(pts) + 2
for v in range(len(pts)):
assert same_cyclic(d.neighbors(v), rot[v])
done += 1
def test_random_operations():
rng = random.Random(2)
for _ in range(500):
d = DCEL()
edges = set()
for _ in range(rng.randint(1, 25)):
op = rng.random()
live = [h for h in range(len(d.origin)) if d.origin[h] != -1]
if not live or op < 0.35:
a = rng.choice(live) if live else -1
h = d.add_leaf(a)
edges.add(frozenset((d.origin[h], d.origin[h ^ 1])))
elif op < 0.75:
f = rng.choice(d.faces())
b = d.face_boundary(f)
a, c = rng.choice(b), rng.choice(b)
u, v = d.origin[a], d.origin[c]
if u == v or frozenset((u, v)) in edges:
continue
h = d.split_face(a, c)
edges.add(frozenset((u, v)))
else:
cand = [h for h in live if d.face[h] != d.face[h ^ 1]]
if not cand:
continue
h = rng.choice(cand)
edges.discard(frozenset((d.origin[h], d.origin[h ^ 1])))
d.join_faces(h)
d.check_invariant()
rot = [d.neighbors(v) for v in range(d.num_vertices())]
got = {frozenset((u, v)) for u in range(len(rot)) for v in rot[u]}
assert got == edges
assert dcel_faces(d) == oracle_faces(rot)
What the next level needs
- the tests did not run in this build