Schnyder drawing algorithm in Python
Straight-line grid drawing of a planar graph on an (n-2) by (n-2) grid using Schnyder woods.
- File
schnyder_drawing.py, 288 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/schnyder-drawing/. The authors are listed in prov/EKZ3MZ.jsonl.
Rotation = dict[int, list[int]]
def _faces(rot: Rotation) -> list[list[int]]:
"""Face walks; the dart after (a, b) is (b, w) with w just before a in rot[b]."""
pos = {(v, w): i for v, around in rot.items() for i, w in enumerate(around)}
seen: set[tuple[int, int]] = set()
out: list[list[int]] = []
for dart in pos:
if dart in seen:
continue
walk = []
d = dart
while d not in seen:
seen.add(d)
walk.append(d[0])
a, b = d
d = (b, rot[b][pos[(b, a)] - 1])
out.append(walk)
return out
def _components(rot: Rotation) -> list[list[int]]:
seen: set[int] = set()
comps = []
for s in rot:
if s in seen:
continue
seen.add(s)
comp, stack = [s], [s]
while stack:
u = stack.pop()
for w in rot[u]:
if w not in seen:
seen.add(w)
comp.append(w)
stack.append(w)
comps.append(comp)
return comps
def _check_planar(rot: Rotation) -> None:
"""Raise ValueError unless rot is a planar rotation system of a simple graph."""
m2 = 0
for v, around in rot.items():
if len(set(around)) != len(around) or v in around:
raise ValueError("the graph must be simple")
for w in around:
if w not in rot or v not in rot[w]:
raise ValueError("the rotation system is not symmetric")
m2 += len(around)
isolated = sum(1 for v in rot if not rot[v])
if len(rot) - m2 // 2 + len(_faces(rot)) + isolated != 2 * len(_components(rot)):
raise ValueError("the rotation system is not planar")
def _edge(u: int, v: int) -> tuple[int, int]:
return (u, v) if u < v else (v, u)
def _blocks(rot: Rotation) -> dict[tuple[int, int], int]:
"""Label every edge with its biconnected component, by iterative DFS."""
disc: dict[int, int] = {}
low: dict[int, int] = {}
label: dict[tuple[int, int], int] = {}
count = 0
for s in rot:
if s in disc:
continue
disc[s] = low[s] = len(disc)
stack = [(s, -1, iter(rot[s]))]
edges: list[tuple[int, int]] = []
while stack:
v, p, it = stack[-1]
descended = False
for w in it:
if w == p:
continue
if w not in disc:
edges.append(_edge(v, w))
disc[w] = low[w] = len(disc)
stack.append((w, v, iter(rot[w])))
descended = True
break
if disc[w] < disc[v]:
edges.append(_edge(v, w))
low[v] = min(low[v], disc[w])
if descended:
continue
stack.pop()
if stack:
u = stack[-1][0]
low[u] = min(low[u], low[v])
if low[v] >= disc[u]:
while True:
e = edges.pop()
label[e] = count
if e == _edge(u, v):
break
count += 1
return label
def triangulate(rot: Rotation) -> Rotation:
"""Add edges to a planar rotation system until every face is a triangle.
Needs at least 3 vertices. Returns a new rotation system; the input is
not changed. The original edges keep their cyclic order.
"""
rot = {v: list(around) for v, around in rot.items()}
adj = {v: set(around) for v, around in rot.items()}
def add(u: int, w: int) -> None:
adj[u].add(w)
adj[w].add(u)
comps = _components(rot)
root = comps[0][0]
for comp in comps[1:]: # join the components at arbitrary corners
r = comp[0]
rot[root].append(r)
rot[r].append(root)
add(root, r)
label = _blocks(rot)
parent = list(range(max(label.values(), default=-1) + 1))
def find(x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for v in list(rot): # remove cut vertices: join consecutive neighbours from different blocks
ring = rot[v]
d = len(ring)
for i in range(d if d > 1 else 0):
a, b = ring[i], ring[(i + 1) % d]
x, y = find(label[_edge(v, a)]), find(label[_edge(v, b)])
if x != y:
rot[a].insert(rot[a].index(v), b)
rot[b].insert(rot[b].index(v) + 1, a)
add(a, b)
parent[x] = y
label[_edge(a, b)] = y
for work in _faces(rot): # every face is now a cycle; cut off triangles
while len(work) > 3:
v0, v1, v2, v3 = work[0], work[1], work[2], work[3]
if v2 not in adj[v0]:
rot[v0].insert(rot[v0].index(v1) + 1, v2)
rot[v2].insert(rot[v2].index(v3) + 1, v0)
add(v0, v2)
work = [v0] + work[2:]
else: # then v1 and v3 are not adjacent, by planarity
v4 = work[4 % len(work)]
rot[v1].insert(rot[v1].index(v2) + 1, v3)
rot[v3].insert(rot[v3].index(v4) + 1, v1)
add(v1, v3)
work = work[:2] + work[3:]
return rot
def schnyder_wood(rot: Rotation, outer: tuple[int, int, int]) -> list[dict[int, int]]:
"""The three parent maps of a Schnyder wood of a plane triangulation.
outer = (a1, a2, a3) lists the outer face counterclockwise, so that
(a1, a3, a2) is a face walk of rot. The result [p1, p2, p3] gives, for
each inner vertex v, its parent pi[v] in the tree rooted at ai.
Raises ValueError if rot is not a triangulation with this face.
"""
a1, a2, a3 = outer
if a1 not in rot[a3] or a2 not in rot[a3] or rot[a3][(rot[a3].index(a2) + 1) % len(rot[a3])] != a1:
raise ValueError("outer is not a face")
on_path = {v: 0 for v in rot} # 0 inside, 1 on the outer path, 2 removed
for a in outer:
on_path[a] = 1
nxt = {a1: a3, a3: a2}
prv = {a3: a1, a2: a3}
chords = {v: 0 for v in rot}
chords[a1] = chords[a2] = 1 # the edge a1 a2
p1: dict[int, int] = {}
p2: dict[int, int] = {}
p3: dict[int, int] = {}
stack = [a3]
removed = 0
while stack: # @line L3
z = stack.pop()
if on_path[z] != 1 or chords[z] != 0 or z == a1 or z == a2:
continue
p, q = prv[z], nxt[z]
ring = rot[z]
d = len(ring)
j = (ring.index(p) + 1) % d
mids = []
while ring[j] != q: # the neighbours of z below the path, from p to q
mids.append(ring[j])
j = (j + 1) % d
on_path[z] = 2
removed += 1
if z != a3:
p1[z], p2[z] = p, q # @line L4
for m in mids:
p3[m] = z
seq = [p] + mids + [q]
for x, y in zip(seq, seq[1:]):
nxt[x], prv[y] = y, x
for m in mids:
on_path[m] = 1
if not mids: # the edge p q was a chord and is now on the path
for x in (p, q):
chords[x] -= 1
if chords[x] == 0:
stack.append(x)
index = {m: i for i, m in enumerate(mids)}
for i, m in enumerate(mids):
for u in rot[m]:
if on_path[u] != 1 or u == prv[m] or u == nxt[m] or index.get(u, len(mids)) < i:
continue
chords[m] += 1
chords[u] += 1
stack.extend(m for m in mids if chords[m] == 0)
if removed != len(rot) - 2:
raise ValueError("the rotation system is not a triangulation")
return [p1, p2, p3]
def schnyder_drawing(rot: Rotation, outer: tuple[int, int, int] | None = None) -> dict[int, tuple[int, int]]:
"""Straight-line planar drawing on the (n-2) x (n-2) grid, for n >= 3.
rot[v] lists the neighbours of v counterclockwise and must be a planar
rotation system of a simple graph. If it is not a triangulation, edges
are added first. outer, a face (a1, a2, a3) of the triangulation listed
counterclockwise, becomes the outer face; by default the face that
contains the first dart. The drawing realizes the rotation system.
a1, a2, a3 go to (n-2, 1), (0, n-2), (1, 0).
Returns integer coordinates in 0..n-2. Raises ValueError if rot is not
a planar rotation system.
"""
_check_planar(rot)
n = len(rot)
if n <= 2:
return {v: (i, 0) for i, v in enumerate(rot)}
tri = rot # @line L1
if 2 * sum(len(a) for a in rot.values()) != 2 * (6 * n - 12) or any(len(f) != 3 for f in _faces(rot)):
tri = triangulate(rot)
if outer is None:
v = next(iter(tri)) # @line L2
w = tri[v][0]
outer = (v, tri[w][tri[w].index(v) - 1], w)
parents = schnyder_wood(tri, outer)
inner = [v for v in tri if v not in outer]
size = [] # @line L5
for i in range(3):
children: dict[int, list[int]] = {v: [] for v in tri}
for v in inner:
children[parents[i][v]].append(v)
order = [outer[i]]
for v in order:
order.extend(children[v])
d = {v: 1 for v in order}
for v in reversed(order[1:]):
d[parents[i][v]] += d[v]
size.append((d, order))
length = [] # @line L6
tail = {}
for j in range(3):
d, order = size[j]
ln = {outer[j]: 1}
s = {(i, outer[j]): 0 for i in range(3)}
for v in order[1:]:
ln[v] = ln[parents[j][v]] + 1
for i in range(3):
s[(i, v)] = s[(i, parents[j][v])] + size[i][0][v] - 1
length.append(ln)
tail[j] = s
pos: dict[int, tuple[int, int]] = {}
for v in inner: # @line L7
c = []
for i in range(3):
a, b = (i + 1) % 3, (i + 2) % 3
region = length[a][v] + length[b][v] - 1 + tail[a][(i, v)] + tail[b][(i, v)] - (size[i][0][v] - 1)
c.append(region - length[b][v])
pos[v] = (c[0], c[1])
a1, a2, a3 = outer # @line L8
pos[a1], pos[a2], pos[a3] = (n - 2, 1), (0, n - 2), (1, 0)
return pos # @line L9
test_schnyder_drawing.py 236 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/schnyder-drawing/. The authors are listed in prov/EKZ3MZ.jsonl.
import math
import random
import pytest
from schnyder_drawing import schnyder_drawing, schnyder_wood, triangulate
Rotation = dict[int, list[int]]
Point = tuple[int, int]
def rotation_from_points(pts: dict[int, Point], edges: list[tuple[int, int]]) -> Rotation:
adj: Rotation = {v: [] for v in pts}
for u, v in edges:
adj[u].append(v)
adj[v].append(u)
return {v: sorted(adj[v], key=lambda w: math.atan2(pts[w][1] - pts[v][1], pts[w][0] - pts[v][0]))
for v in pts}
def orient(a: Point, b: Point, c: Point) -> int:
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
def on_segment(a: Point, b: Point, p: Point) -> bool:
return (orient(a, b, p) == 0 and min(a[0], b[0]) <= p[0] <= max(a[0], b[0])
and min(a[1], b[1]) <= p[1] <= max(a[1], b[1]))
def proper_cross(a: Point, b: Point, c: Point, d: Point) -> bool:
return orient(a, b, c) * orient(a, b, d) < 0 and orient(c, d, a) * orient(c, d, b) < 0
def edges_of(rot: Rotation) -> list[tuple[int, int]]:
return [(u, v) for u in rot for v in rot[u] if u < v]
def is_plane_drawing(rot: Rotation, pos: dict[int, Point]) -> bool:
"""Exact check: distinct points, no vertex on another edge, no crossing."""
if len(set(pos.values())) != len(pos):
return False
es = edges_of(rot)
for u, v in es:
if any(w != u and w != v and on_segment(pos[u], pos[v], pos[w]) for w in rot):
return False
for i, (a, b) in enumerate(es):
for c, d in es[i + 1:]:
if len({a, b, c, d}) == 4 and proper_cross(pos[a], pos[b], pos[c], pos[d]):
return False
return True
def keeps_rotation(rot: Rotation, pos: dict[int, Point]) -> bool:
for v, ring in rot.items():
if len(ring) < 2:
continue
got = sorted(ring, key=lambda w: math.atan2(pos[w][1] - pos[v][1], pos[w][0] - pos[v][0]))
i = got.index(ring[0])
if got[i:] + got[:i] != ring:
return False
return True
def check(rot: Rotation) -> dict[int, Point]:
pos = schnyder_drawing(rot)
n = len(rot)
assert set(pos) == set(rot)
if n >= 3:
assert all(0 <= x <= n - 2 and 0 <= y <= n - 2 for x, y in pos.values())
assert is_plane_drawing(rot, pos)
assert keeps_rotation(rot, pos)
return pos
def stacked(rng: random.Random, n: int) -> Rotation:
rot: Rotation = {0: [1, 2], 1: [2, 0], 2: [0, 1]}
faces = [(0, 1, 2), (0, 2, 1)]
for v in range(3, n):
i = rng.randrange(len(faces))
x, y, z = faces[i]
faces[i] = (x, y, v)
faces += [(y, z, v), (z, x, v)]
for p, q in ((x, y), (y, z), (z, x)):
rot[p].insert(rot[p].index(q) + 1, v)
rot[v] = [x, y, z]
return rot
def point_graph(rng: random.Random, n: int, keep: float, frame: bool) -> Rotation:
"""Greedy maximal plane graph on random points, then a random subset of edges."""
pts: dict[int, Point] = {}
while len(pts) < n:
p = (rng.randint(0, 40), rng.randint(0, 40))
if p not in pts.values():
pts[len(pts)] = p
if frame:
pts[n], pts[n + 1], pts[n + 2] = (-500, -500), (500, -499), (0, 500)
vs = list(pts)
pairs = [(u, v) for u in vs for v in vs if u < v]
rng.shuffle(pairs)
es: list[tuple[int, int]] = []
for u, v in pairs:
if any(w != u and w != v and on_segment(pts[u], pts[v], pts[w]) for w in vs):
continue
if any(len({u, v, a, b}) == 4 and proper_cross(pts[u], pts[v], pts[a], pts[b]) for a, b in es):
continue
es.append((u, v))
if not frame:
es = [e for e in es if rng.random() < keep]
rot = rotation_from_points(pts, es)
perm = vs[:]
rng.shuffle(perm)
return {perm[v]: [perm[w] for w in rot[v]] for v in rng.sample(vs, len(vs))}
OCTAHEDRON = {0: [1, 4, 3, 2], 1: [2, 5, 4, 0], 2: [0, 3, 5, 1],
3: [0, 4, 5, 2], 4: [0, 1, 5, 3], 5: [4, 1, 2, 3]}
def test_page_example() -> None:
p1, p2, p3 = schnyder_wood(OCTAHEDRON, (0, 1, 2))
assert p1 == {3: 0, 4: 0, 5: 3} and p2 == {3: 4, 4: 1, 5: 1} and p3 == {3: 2, 4: 5, 5: 2}
pos = schnyder_drawing(OCTAHEDRON, (0, 1, 2))
assert pos == {0: (4, 1), 1: (0, 4), 2: (1, 0), 3: (3, 1), 4: (1, 3), 5: (1, 1)}
assert is_plane_drawing(OCTAHEDRON, pos) and keeps_rotation(OCTAHEDRON, pos)
def test_small_cases() -> None:
assert schnyder_drawing({}) == {}
assert schnyder_drawing({7: []}) == {7: (0, 0)}
assert schnyder_drawing({0: [1], 1: [0]}) == {0: (0, 0), 1: (1, 0)}
k4 = {0: [1, 2, 3], 1: [2, 0, 3], 2: [0, 1, 3], 3: [0, 2, 1]}
assert check(k4) == {0: (2, 1), 1: (1, 0), 2: (0, 2), 3: (1, 1)}
triangle = {0: [1, 2], 1: [2, 0], 2: [0, 1]}
check(triangle)
path = {i: [j for j in (i - 1, i + 1) if 0 <= j < 6] for i in range(6)}
check(path)
star = {0: [1, 2, 3, 4, 5], 1: [0], 2: [0], 3: [0], 4: [0], 5: [0]}
check(star)
check({v: [] for v in range(5)})
def test_nonplanar_inputs_are_rejected() -> None:
k5 = {v: [w for w in range(5) if w != v] for v in range(5)}
k33 = {v: ([3, 4, 5] if v < 3 else [0, 1, 2]) for v in range(6)}
toroidal_k4 = {0: [1, 2, 3], 1: [0, 2, 3], 2: [0, 1, 3], 3: [0, 1, 2]}
for rot in (k5, k33, toroidal_k4):
with pytest.raises(ValueError):
schnyder_drawing(rot)
def test_schnyder_wood_rule() -> None:
rng = random.Random(5)
for _ in range(100):
rot = stacked(rng, rng.randint(3, 40)) if rng.random() < 0.5 else point_graph(rng, rng.randint(1, 12), 1, True)
v = next(iter(rot))
w = rot[v][0]
outer = (v, rot[w][rot[w].index(v) - 1], w)
parents = schnyder_wood(rot, outer)
inner = [u for u in rot if u not in outer]
colour: dict[tuple[int, int], int] = {}
for i in range(3):
assert set(parents[i]) == set(inner)
for u in inner:
colour[(u, parents[i][u])] = i
seen, x = set(), u
while x in parents[i]:
assert x not in seen
seen.add(x)
x = parents[i][x]
assert x == outer[i]
assert len(colour) == 3 * len(inner) and all((b, a) not in colour for a, b in colour)
for u in inner: # out 1, in 3, out 2, in 1, out 3, in 2, counterclockwise
ring = rot[u]
k = ring.index(parents[0][u])
word = []
for x in ring[k:] + ring[:k]:
word.append(("out", colour[(u, x)]) if (u, x) in colour else ("in", colour[(x, u)]))
groups = [word[0]] + [t for s, t in zip(word, word[1:]) if s != t]
assert [g for g in groups if g[0] == "out"] == [("out", 0), ("out", 1), ("out", 2)]
for s, t in zip(word, word[1:] + word[:1]):
if s[0] == "out" and t[0] == "in":
assert t[1] == (s[1] + 2) % 3
for i, a in enumerate(outer):
assert all(colour[(x, a)] == i for x in rot[a] if x in inner)
def test_triangulate() -> None:
rng = random.Random(3)
for _ in range(100):
rot = point_graph(rng, rng.randint(3, 12), rng.random(), False)
tri = triangulate(rot)
n = len(rot)
assert sum(len(a) for a in tri.values()) == 2 * (3 * n - 6)
for v in rot:
assert len(set(tri[v])) == len(tri[v])
kept = [w for w in tri[v] if w in rot[v]]
if kept:
i = kept.index(rot[v][0])
assert kept[i:] + kept[:i] == rot[v]
def test_random_against_oracle() -> None:
rng = random.Random(2024)
for trial in range(600):
kind = trial % 3
if kind == 0:
rot = stacked(rng, rng.randint(3, 30))
elif kind == 1:
rot = point_graph(rng, rng.randint(1, 12), 1, True)
else:
rot = point_graph(rng, rng.randint(1, 14), rng.random(), False)
check(rot)
def test_large_input() -> None:
rng = random.Random(1)
n = 4000
rot = stacked(rng, n)
pos = schnyder_drawing(rot)
corners = {v for v, p in pos.items() if p in ((n - 2, 1), (0, n - 2), (1, 0))}
assert len(corners) == 3
assert all(0 <= x <= n - 2 and 0 <= y <= n - 2 for x, y in pos.values())
faces = set()
for u in rot:
for v in rot[u]:
w = rot[v][rot[v].index(u) - 1]
faces.add(min((u, v, w), (v, w, u), (w, u, v)))
assert len(faces) == 2 * n - 4
for u, v, w in faces: # every inner face keeps its orientation, the outer one flips
sign = orient(pos[u], pos[v], pos[w])
assert sign != 0
assert (sign < 0) == ({u, v, w} == corners)
What the next level needs
- the tests did not run in this build