Algorithm Search⌘K

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
schnyder_drawing.pyRawGitHub
# 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

Read the full page about Schnyder drawing algorithm