Skip to content
The Lumenary

Math laboratory

Gluing polygons into surfaces: chi, orientability and genus

Computes V, E, F, the Euler characteristic and orientability of surfaces given by edge words such as abAB (torus) and abab (projective plane), names them by the classification theorem, then glues a 2n-gon in all (2n-1)!! orientable ways for n <= 5 and counts the surfaces by genus, recovering the Catalan numbers for the spheres.

Back to the labExact computationExhaustive finite check
Gluing polygons into surfaces: chi, orientability and genusPythonExact computationExhaustive finite checkComputationally tested
"""Surfaces from gluing the sides of a polygon: chi, orientability, and a census.

A word such as "abAB" describes a polygon whose sides, read in order, are
glued in pairs: a lower-case letter is a side traversed forwards, the matching
capital letter the partner side traversed backwards. The program finds the
vertex classes after gluing (V), counts edges (E) and faces (F = 1), computes
chi = V - E + F, decides orientability (a surface word is orientable exactly
when every letter occurs once forwards and once backwards), and names the
surface using the classification theorem. Then it glues the sides of a 2n-gon
in every orientable way and counts the results by genus; the genus-0 counts
are the Catalan numbers. Exact and exhaustive, but only for finitely many n.
"""

import itertools
from math import comb

def sides(word):
    """List of (letter, +1/-1) for a word like 'abAB'."""
    return [(ch.lower(), 1 if ch.islower() else -1) for ch in word]

def vertex_count(word):
    """Number of vertex classes after gluing the sides of the polygon.

    Corner i sits at the start of side i. Gluing side i to its partner j
    identifies the start and end of side i with the corresponding ends of
    side j (swapped when the two sides run in the same direction).
    """
    s = sides(word)
    n = len(s)
    parent = list(range(n))

    def find(x):
        while parent[x] != x:
            parent[x] = parent[parent[x]]
            x = parent[x]
        return x

    def union(x, y):
        parent[find(x)] = find(y)

    positions = {}
    for i, (letter, _) in enumerate(s):
        positions.setdefault(letter, []).append(i)
    for letter, (i, j) in positions.items():
        tail_i, head_i = (i, (i + 1) % n)
        tail_j, head_j = (j, (j + 1) % n)
        if s[i][1] == -1:
            tail_i, head_i = head_i, tail_i
        if s[j][1] == -1:
            tail_j, head_j = head_j, tail_j
        union(tail_i, tail_j)
        union(head_i, head_j)
    return len({find(x) for x in range(n)})

def is_orientable(word):
    """Each letter appears once with each exponent."""
    s = sides(word)
    return all(sorted(e for l, e in s if l == letter) == [-1, 1]
               for letter in {l for l, _ in s})

def euler_characteristic(word):
    """chi = V - E + F for the one-polygon surface given by the word."""
    return vertex_count(word) - len(word) // 2 + 1

def name(chi, orientable):
    """Name a closed connected surface by the classification theorem."""
    if orientable:
        g = (2 - chi) // 2
        return {0: "sphere", 1: "torus"}.get(g, f"orientable, genus {g}")
    k = 2 - chi
    return {1: "projective plane", 2: "Klein bottle"}.get(
        k, f"sum of {k} projective planes")

def pairings(items):
    """All perfect matchings of a list of even length."""
    if not items:
        yield []
        return
    first, rest = items[0], items[1:]
    for k in range(len(rest)):
        for m in pairings(rest[:k] + rest[k + 1:]):
            yield [(first, rest[k])] + m

def genus_census(n):
    """{genus: count} over all orientable gluings of a 2n-gon."""
    counts = {}
    letters = "abcdefghij"
    for matching in pairings(list(range(2 * n))):
        word = [""] * (2 * n)
        for k, (i, j) in enumerate(matching):
            word[i], word[j] = letters[k], letters[k].upper()
        w = "".join(word)
        g = (2 - euler_characteristic(w)) // 2
        counts[g] = counts.get(g, 0) + 1
    return counts

def catalan(n):
    """The n-th Catalan number."""
    return comb(2 * n, n) // (n + 1)

def main():
    examples = ["aA", "abAB", "abAb", "abab", "aabb", "abcABC",
                "abABcdCD", "abABcc", "aabbcc"]
    print(f"{'word':<10}{'V':>3}{'E':>3}{'F':>3}{'chi':>5}  orientable  surface")
    for w in examples:
        v, e = vertex_count(w), len(w) // 2
        chi = v - e + 1
        o = is_orientable(w)
        print(f"{w:<10}{v:>3}{e:>3}{1:>3}{chi:>5}  {str(o):<10}  {name(chi, o)}")
    print("Note: abABcc (torus # projective plane) has the same chi and")
    print("orientability as aabbcc, so by classification they are the same.")
    print()
    print("All orientable gluings of a 2n-gon, counted by genus:")
    for n in range(1, 6):
        counts = genus_census(n)
        total = sum(counts.values())
        row = ", ".join(f"g{g}: {c}" for g, c in sorted(counts.items()))
        print(f"  n = {n}: {total:>3} gluings; {row};"
              f" Catalan({n}) = {catalan(n)}")
    print()
    print("Epistemic status: exact and exhaustive for the words and the n shown.")
    print("That the genus-0 count is always Catalan (and the Harer-Zagier")
    print("formula for higher genus) is a theorem; this is evidence for n <= 5.")

if __name__ == "__main__":
    main()
Runs entirely in your browser, in an isolated worker. Nothing is sent to a server.

Used in