Skip to content

Index

pleat.gjh

GomJau-Hogg notation: compile tiling codes into tilesets and graphs.

The GomJau-Hogg notation describes uniform Euclidean tilings as a sequence of construction stages separated by /:

  • Stage 1 places polygons starting from a seed (e.g. "6-3-3" = hex, triangle, triangle).
  • Stage 2+ apply rotations or mirrors (e.g. "m30", "r(h2)", "r(c3)") to expand the placement into a full tiling.

See Gómez-Jáuregui & Hogg, *Symmetry* 13(12), 2021 <https://www.mdpi.com/2073-8994/13/12/2376>__.

Public API

  • :func:gjh — code → list[RegularEuclideanTile] (cached if known, compiled otherwise).
  • :func:gjh_spec — code → :data:~pleat.tileset_spec.TilesetSpec.
  • :func:gjh_graph — code → finite expanded :class:~pleat.half.EuclideanPositionHEG.
  • :data:GJH_CODES — ordered list of all codes in the cached library.
  • :func:cached_spec — strict cache-only lookup; raises :class:KeyError if the code is not in :data:GJH_CODES.
  • :func:compile_gjh_spec — bypass cache: always run the parser + distiller.

Example::

import pleat
from pleat.gjh import gjh

tiles = gjh("6-3-3")
G = pleat.example_graphs.from_tiles(tiles, rings=5)
G.show()

spec_from_graph

spec_from_graph(G: EuclideanPositionHEG) -> TilesetSpec

Analyse a finite tiled Euclidean graph and produce a :data:TilesetSpec.

Source code in pleat/gjh/distill.py
def spec_from_graph(G: EuclideanPositionHEG) -> TilesetSpec:
    """Analyse a finite tiled Euclidean graph and produce a :data:`TilesetSpec`."""
    cc = congruency_classifier()
    fs = list(G.faces)
    face_dicts: list[dict[Face, int]] = [{f: cc.classify(f) for f in fs}]

    hs = list(G.halfedges)
    lengths = np.array([np.linalg.norm(h.orig["pos"] - h.dest["pos"]) for h in hs])
    length_groups = fast_group_closeby(lengths[:, None], eps=1e-6)
    edge_dicts: list[dict[HalfEdge, int]] = [{h: int(lg) for h, lg in zip(hs, length_groups)}]

    before = 0
    for _ in count():
        hc = _EdgeClassifier(face_dicts[-1], edge_dicts[-1])
        edge_dicts.append({h: hc.classify(h) for h in hs})

        fc = _FaceClassifier(edge_dicts[-1])
        face_dicts.append({f: fc.classify(f) for f in fs})

        after = len(set(edge_dicts[-1].values())) + len(set(face_dicts[-1].values()))
        if before >= after:
            break
        before = after

    # Pick one exemplar face per class (skipping classes that touched the open border).
    exemplar_fs: list[Face] = []
    for key in set(face_dicts[-2].values()):
        if key < 0:
            continue
        exemplar_fs.append(next(f for f in fs if face_dicts[-2][f] == key and face_dicts[-1][f] >= 0))
    exemplar_fs.sort(key=lambda f: -f.order())

    tile_names = {f: chr(97 + i) for i, f in enumerate(exemplar_fs)}
    edge_names: dict[int, tuple[str, int]] = {}
    for f in exemplar_fs:
        for i, h in enumerate(f.halfedge_iter()):
            edge_class = edge_dicts[-2][h]
            if edge_class not in edge_names:
                edge_names[edge_class] = (tile_names[f], i)

    return {tile_names[f]: [edge_names[edge_dicts[-2][h.rev]] for h in f.halfedge_iter()] for f in exemplar_fs}

cached_spec

cached_spec(code: str) -> TilesetSpec

Return the cached :data:TilesetSpec for code. Raises :class:KeyError if unknown.

Source code in pleat/gjh/library.py
def cached_spec(code: str) -> TilesetSpec:
    """Return the cached :data:`TilesetSpec` for ``code``. Raises :class:`KeyError` if unknown."""
    code = code.replace(" ", "")
    try:
        return CACHED_SPECS[code]
    except KeyError as e:
        raise KeyError(f"GJH code {code!r} is not in the cached library") from e

apply_transform

apply_transform(
    G: EuclideanPositionHEG, code: str
) -> list[np.ndarray]

Parse a stage-2+ transform code ("m30", "r(h2)", "r(c3)", "m", "r") into matrices.

Parameters:

Name Type Description Default
G EuclideanPositionHEG

The current graph; used to look up face centers, vertices, and edges referenced by c<i> / v<i> / h<i> origin specifiers.

required
code str

One transform stage. Possible forms:

  • m<deg> or r<deg>: mirror or rotate at the origin by an angle in degrees (with subsequent doublings filled in automatically).
  • m / r (no angle): equivalent to m180 / r180.
  • m(<origin>) or r(<origin>): mirror/rotate about an origin specifier, where <origin> is c<i> (i-th face center), v<i> (i-th vertex), or h<i> (i-th edge midpoint / edge line).
required

Returns:

Type Description
list[ndarray]

A list of one or more 3x3 affine matrices (homogeneous 2D transforms).

Source code in pleat/gjh/parser.py
def apply_transform(G: EuclideanPositionHEG, code: str) -> list[np.ndarray]:
    """Parse a stage-2+ transform code (``"m30"``, ``"r(h2)"``, ``"r(c3)"``, ``"m"``, ``"r"``) into matrices.

    Args:
        G: The current graph; used to look up face centers, vertices, and edges
            referenced by ``c<i>`` / ``v<i>`` / ``h<i>`` origin specifiers.
        code: One transform stage. Possible forms:

            * ``m<deg>`` or ``r<deg>``: mirror or rotate at the origin by an angle in degrees
              (with subsequent doublings filled in automatically).
            * ``m`` / ``r`` (no angle): equivalent to ``m180`` / ``r180``.
            * ``m(<origin>)`` or ``r(<origin>)``: mirror/rotate about an origin specifier,
              where ``<origin>`` is ``c<i>`` (i-th face center), ``v<i>`` (i-th vertex),
              or ``h<i>`` (i-th edge midpoint / edge line).

    Returns:
        A list of one or more 3x3 affine matrices (homogeneous 2D transforms).
    """
    parts = code.split("(")
    mode = parts[0][0]
    if mode not in ("r", "m"):
        raise ValueError(f"Transform type must be 'r' or 'm'; got {mode!r}")
    angle_str = parts[0][1:]
    angle = np.pi / 180 * int(angle_str) if angle_str else None

    if len(parts) == 1:
        angle = np.pi if angle is None else angle
        if angle <= 0:
            raise ValueError(f"Invalid transform angle: {angle}")
        angles = [angle]
        while 2 * angles[-1] < 2 * np.pi:
            angles.append(angles[-1] * 2)
        if mode == "m":
            return [_mirror_mat_line(np.stack([np.zeros(2), _unit_vector_from_y(a)])) for a in angles]
        return [_rotation_mat(a) for a in angles]

    if angle is not None:
        raise ValueError(f"Specify either an angle or an origin, not both: {code!r}")
    origin_code = parts[1]
    if not origin_code.endswith(")"):
        raise ValueError(f"Unterminated origin specifier: {code!r}")
    origin_code = origin_code[:-1]
    origin_type, index_str = origin_code[0], origin_code[1:]
    idx = int(index_str) - 1

    if origin_type == "c":
        points = np.stack([f.midpoint() for f in G.faces])
        point = points[_order_points(points)[idx]]
        return [_mirror_mat_point(point) if mode == "m" else _rotation_mat_point(point)]
    if origin_type == "v":
        points = np.stack([v["pos"] for v in G.vertices])
        point = points[_order_points(points)[idx]]
        return [_mirror_mat_point(point) if mode == "m" else _rotation_mat_point(point)]
    if origin_type == "h":
        sides = np.stack([np.stack([h.orig["pos"], h.dest["pos"]]) for h in G.halfedges_representing_edges()])
        points = sides.mean(1)
        order = _order_points(points)
        if mode == "m":
            return [_mirror_mat_line(sides[order[idx]])]
        return [_rotation_mat_point(points[order[idx]])]
    raise ValueError(f"Origin type must be 'c', 'v', or 'h'; got {origin_type!r}")

compile_gjh_graph

compile_gjh_graph(
    code: str, bbox_size: float = 20.0
) -> EuclideanPositionHEG

Compile a full GJH code into a finite tiled :class:EuclideanPositionHEG.

Parameters:

Name Type Description Default
code str

A full GJH code, e.g. "6-3-3/r60/r(h5)".

required
bbox_size float

Side length of the square bounding box (centred at the origin) within which to expand the tiling. Larger values produce more tiles.

20.0

Returns:

Type Description
EuclideanPositionHEG

The expanded tiling as a Euclidean half-edge graph.

Source code in pleat/gjh/parser.py
def compile_gjh_graph(code: str, bbox_size: float = 20.0) -> EuclideanPositionHEG:
    """Compile a full GJH code into a finite tiled :class:`EuclideanPositionHEG`.

    Args:
        code: A full GJH code, e.g. ``"6-3-3/r60/r(h5)"``.
        bbox_size: Side length of the square bounding box (centred at the origin)
            within which to expand the tiling. Larger values produce more tiles.

    Returns:
        The expanded tiling as a Euclidean half-edge graph.
    """
    code = code.replace(" ", "")
    stages = code.split("/")
    G = polygon_placement(stages[0])
    tiles = [_Tile.from_face(f) for f in G.faces]

    mats: list[np.ndarray] = []
    for stage in stages[1:]:
        ms = apply_transform(G, stage)
        for m in ms:
            tiles = _add_transformed_tiles(tiles, m)
        mats.extend(ms)
        if len(tiles) > len(G.faces):
            G = _tiles_to_graph(tiles)

    _MAX_EXPANSION_ITERS = 1000
    for i in itertools.count():
        n_before = len(tiles)
        for m in mats:
            tiles = _add_transformed_tiles(tiles, m, center_filter=lambda c: np.max(np.abs(c)) < bbox_size / 2)
        if len(tiles) == n_before:
            break
        if i >= _MAX_EXPANSION_ITERS:
            warnings.warn(
                f"compile_gjh_graph hit the {_MAX_EXPANSION_ITERS}-iteration expansion cap "
                f"for code {code!r} at bbox_size={bbox_size}; tiling may be incomplete.",
                stacklevel=2,
            )
            break

    return _tiles_to_graph(tiles)

polygon_placement

polygon_placement(code: str) -> EuclideanPositionHEG

Parse the first stage of a GJH code (polygons separated by - and ,) into a graph.

Parameters:

Name Type Description Default
code str

First stage of a GJH code, e.g. "6", "6-3-3", "4-3-0,4". A 0 in a phase means "skip this attachment slot".

required

Returns:

Type Description
EuclideanPositionHEG

A small Euclidean half-edge graph containing all placed polygons.

Source code in pleat/gjh/parser.py
def polygon_placement(code: str) -> EuclideanPositionHEG:
    """Parse the first stage of a GJH code (polygons separated by ``-`` and ``,``) into a graph.

    Args:
        code: First stage of a GJH code, e.g. ``"6"``, ``"6-3-3"``, ``"4-3-0,4"``.
            A ``0`` in a phase means "skip this attachment slot".

    Returns:
        A small Euclidean half-edge graph containing all placed polygons.
    """
    code = code.replace(" ", "")
    phases = [[int(n) for n in c.split(",")] for c in code.split("-")]

    if len(phases[0]) != 1:
        raise ValueError(f"Seed phase must consist of one polygon; got {phases[0]}")
    G = seed_polygon(phases[0][0])
    seed_face = next(iter(G.faces))

    for phase in phases[1:]:
        # Tag each existing border half-edge so we can later restrict attachment
        # to edges added in the most recent phase only.
        for h in (h for h in G.halfedges if h.on_border()):
            h["old"] = h.attributes.get("old", 0) + 1

        attach_at_list = [_starting_border(G, seed_face)]
        while True:
            attach_at_list.append(attach_at_list[-1].nex)
            if attach_at_list[-1] is attach_at_list[0]:
                break
        attach_at_list = attach_at_list[:-1]
        attach_at_list = [h for h in attach_at_list if h.attributes.get("old", 0) <= 1]

        polys = [seed_polygon(n) if n > 0 else None for n in phase]
        i = 0
        for poly in polys:
            try:
                while not (attach_at_list[i].on_border() and attach_at_list[i] in G.halfedges):
                    i += 1
                attach_at = attach_at_list[i]
            except IndexError as e:
                raise IndexError(
                    f"Not enough new edges to attach polygons {phase} "
                    f"(only {len(attach_at_list)} attachment points available)"
                ) from e
            i += 1
            if poly is None:
                continue
            G.glue_graph_e2e(poly, attach_at, next(h for h in poly.halfedges if h.on_border()))
    return G

gjh_spec

gjh_spec(code: str, bbox_size: float = 20.0) -> TilesetSpec

Return the :data:~pleat.tileset_spec.TilesetSpec for a GJH code.

If code is in :data:GJH_CODES the cached spec is returned (fast). Otherwise the parser and distiller are run with the given bbox_size.

Source code in pleat/gjh/__init__.py
def gjh_spec(code: str, bbox_size: float = 20.0) -> TilesetSpec:
    """Return the :data:`~pleat.tileset_spec.TilesetSpec` for a GJH code.

    If ``code`` is in :data:`GJH_CODES` the cached spec is returned (fast). Otherwise
    the parser and distiller are run with the given ``bbox_size``.
    """
    code = code.replace(" ", "")
    if code in CACHED_SPECS:
        return CACHED_SPECS[code]
    return compile_gjh_spec(code, bbox_size=bbox_size)

gjh

gjh(
    code: str, bbox_size: float = 20.0
) -> list[RegularEuclideanTile]

Return a list of :class:RegularEuclideanTile with edge instructions wired up.

Equivalent to :func:pleat.tileset_spec.tileset_from_spec applied to :func:gjh_spec. The result is ready to pass to :func:pleat.example_graphs.from_tiles.

Source code in pleat/gjh/__init__.py
def gjh(code: str, bbox_size: float = 20.0) -> list[RegularEuclideanTile]:
    """Return a list of :class:`RegularEuclideanTile` with edge instructions wired up.

    Equivalent to :func:`pleat.tileset_spec.tileset_from_spec` applied to
    :func:`gjh_spec`. The result is ready to pass to
    :func:`pleat.example_graphs.from_tiles`.
    """
    return tileset_from_spec(gjh_spec(code, bbox_size=bbox_size))

gjh_graph

gjh_graph(
    code: str, bbox_size: float = 20.0
) -> EuclideanPositionHEG

Return the raw expanded :class:EuclideanPositionHEG for a GJH code.

Unlike :func:gjh, this never consults the cache — it always runs the full parser pipeline. Useful for inspection, debugging, or visualising intermediate state during code authoring.

Source code in pleat/gjh/__init__.py
def gjh_graph(code: str, bbox_size: float = 20.0) -> EuclideanPositionHEG:
    """Return the raw expanded :class:`EuclideanPositionHEG` for a GJH code.

    Unlike :func:`gjh`, this never consults the cache — it always runs the
    full parser pipeline. Useful for inspection, debugging, or visualising
    intermediate state during code authoring.
    """
    return compile_gjh_graph(code, bbox_size=bbox_size)

compile_gjh_spec

compile_gjh_spec(
    code: str, bbox_size: float = 20.0
) -> TilesetSpec

Run the parser + distiller pipeline, bypassing the cached library.

Raises:

Type Description
ValueError

If code has no transform stage (e.g. "6-3-3"); without at least one /m… or /r… stage the seed graph has no interior faces, so distillation produces an empty spec. A full GJH code always includes one or more transform stages, e.g. "6-3-3/r60/r(h5)".

Source code in pleat/gjh/__init__.py
def compile_gjh_spec(code: str, bbox_size: float = 20.0) -> TilesetSpec:
    """Run the parser + distiller pipeline, bypassing the cached library.

    Raises:
        ValueError: If ``code`` has no transform stage (e.g. ``"6-3-3"``);
            without at least one ``/m…`` or ``/r…`` stage the seed graph has no
            interior faces, so distillation produces an empty spec. A full GJH
            code always includes one or more transform stages, e.g.
            ``"6-3-3/r60/r(h5)"``.
    """
    code = code.replace(" ", "")
    if "/" not in code:
        raise ValueError(
            f"GJH code {code!r} has no transform stage. "
            f"Add at least one '/m…' or '/r…' stage, e.g. '{code}/m30' or '{code}/r(h1)'."
        )
    G = compile_gjh_graph(code, bbox_size=bbox_size)
    return spec_from_graph(G)