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(
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:
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)
|