Skip to content

parser

pleat.gjh.parser

Parse a GomJau-Hogg code into a finite half-edge graph.

Pipeline:

  1. :func:polygon_placement parses the first stage (e.g. "6-3-3") into a small seed graph by gluing regular polygons together.
  2. :func:apply_transform interprets each later stage (e.g. "m30", "r(h2)", "r(c3)") as one or more affine transforms.
  3. :func:compile_gjh_graph applies the transforms iteratively, expanding the graph until no new tiles fit within the requested bounding box.

seed_polygon

seed_polygon(n: int) -> EuclideanPositionHEG

Construct an isolated regular n-gon graph (oriented for use as a seed).

Source code in pleat/gjh/parser.py
def seed_polygon(n: int) -> EuclideanPositionHEG:
    """Construct an isolated regular n-gon graph (oriented for use as a seed)."""
    G = EuclideanPositionHEG(other=RegularEuclideanTile(n).make_graph(add_positions=True)[0])
    if n == 3:
        # Triangles need to be re-oriented so their border edge lies on the negative x side,
        # matching the convention used by ``starting_border``.
        pos = G.get_position_view(return_vertices=False)
        pos *= -1
        pos -= pos.min(0, keepdims=True)
    return G

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

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)