Module weavingspace.tiling_geometries
Functions for setting up a TileUnit
with various
tile geometries. Some care is required in adding new functions that use
exisitng ones to get the sequence of setup operations right. Modify with care!
The available tilings can be viewed on this page.
These tilings (and many many more!) are discussed in
Grunbaum B, Shephard G C, 1987 Tilings and Patterns (W. H. Freeman and Company, New York)
A more accessible introduction is
Kaplan C S, 2002 Computer Graphics and Geometric Ornamental Design, PhD thesis, University of Washington, Seattle, WA, https://cs.uwaterloo.ca/~csk/other/phd/kaplan_diss_full_print.pdf
and a more 'polished' version of that work focused on computer graphics is also available
Kaplan C S, 2009 Introductory tiling theory for computer graphics (Morgan & Claypool)
Functions
def get_4_parts_of_hexagon(unit: TileUnit) ‑> list[shapely.geometry.polygon.Polygon]
-
Expand source code
def get_4_parts_of_hexagon(unit: "TileUnit") -> list[geom.Polygon]: outer_h = tiling_utils.get_regular_polygon(unit.spacing, 6) inner_h = affine.scale(outer_h, 1/np.sqrt(3), 1/np.sqrt(3)) if unit.offset == 1: inner_h = affine.rotate(inner_h, 30, (0, 0)) o_hx = tiling_utils.get_corners(outer_h) i_hx = tiling_utils.get_corners(inner_h) if unit.offset == 1: o = [] for p1, p2 in zip(o_hx[:-1], o_hx[1:]): o.extend([p1, geom.Point([(p1.x + p2.x) / 2, (p1.y + p2.y) / 2])]) return [ inner_h, geom.Polygon([i_hx[2], i_hx[1], i_hx[0], o[11], o[0], o[2], o[3]]), geom.Polygon([i_hx[4], i_hx[3], i_hx[2], o[3], o[4], o[6], o[7]]), geom.Polygon([i_hx[0], i_hx[5], i_hx[4], o[7], o[8], o[10], o[11]]) ] else: return [ inner_h, geom.Polygon([i_hx[2], i_hx[1], i_hx[0], o_hx[0], o_hx[1], o_hx[2]]), geom.Polygon([i_hx[4], i_hx[3], i_hx[2], o_hx[2], o_hx[3], o_hx[4]]), geom.Polygon([i_hx[0], i_hx[5], i_hx[4], o_hx[4], o_hx[5], o_hx[0]]) ]
def get_7_parts_of_hexagon(unit: TileUnit) ‑> list[shapely.geometry.polygon.Polygon]
-
Expand source code
def get_7_parts_of_hexagon(unit: "TileUnit") -> list[geom.Polygon]: outer_h = tiling_utils.get_regular_polygon(unit.spacing, 6) inner_h = affine.scale(outer_h, 1/np.sqrt(7), 1/np.sqrt(7)) if unit.offset == 1: inner_h = affine.rotate(inner_h, 30, (0, 0)) outer = tiling_utils.get_corners(outer_h) inner = tiling_utils.get_corners(inner_h) if unit.offset == 1: o = [] for p1, p2 in zip(outer[:-1], outer[1:]): o.extend([p1, geom.Point([(p1.x + p2.x) / 2, (p1.y + p2.y) / 2])]) i = [] for p1, p2 in zip(inner[:-1], inner[1:]): i.extend([p1, geom.Point([(p1.x + p2.x) / 2, (p1.y + p2.y) / 2])]) return [ inner_h, geom.Polygon([i[2], i[0], o[11], o[0], o[1]]), geom.Polygon([i[4], i[2]] + o[1:4]), geom.Polygon([i[6], i[4]] + o[3:6]), geom.Polygon([i[8], i[6]] + o[5:8]), geom.Polygon([i[10], i[8]] + o[7:10]), geom.Polygon([i[0], i[10]] + o[9:]) ] else: return [ inner_h, geom.Polygon([inner[1], inner[0], outer[0], outer[1]]), geom.Polygon([inner[2], inner[1], outer[1], outer[2]]), geom.Polygon([inner[3], inner[2], outer[2], outer[3]]), geom.Polygon([inner[4], inner[3], outer[3], outer[4]]), geom.Polygon([inner[5], inner[4], outer[4], outer[5]]), geom.Polygon([inner[0], inner[5], outer[5], outer[0]]) ]
def get_9_parts_of_hexagon(unit: TileUnit) ‑> list[shapely.geometry.polygon.Polygon]
-
Expand source code
def get_9_parts_of_hexagon(unit: "TileUnit") -> list[geom.Polygon]: c = geom.Point(0, 0) outer_h = tiling_utils.get_regular_polygon(unit.spacing, 6) inner_h = affine.scale(outer_h, 1/np.sqrt(3), 1/np.sqrt(3)) if unit.offset == 1: inner_h = affine.rotate(inner_h, 30, (0, 0)) outer = tiling_utils.get_corners(outer_h) inner = tiling_utils.get_corners(inner_h) if unit.offset == 1: o = [] for p1, p2 in zip(outer[:-1], outer[1:]): o.extend([p1, geom.Point([(p1.x + p2.x) / 2, (p1.y + p2.y) / 2])]) i = [] for p1, p2 in zip(inner[:-1], inner[1:]): i.extend([p1, geom.Point([(p1.x + p2.x) / 2, (p1.y + p2.y) / 2])]) return [ geom.Polygon([c, i[1], i[2], i[4], i[5]]), geom.Polygon([c, i[5], i[6], i[8], i[9]]), geom.Polygon([c, i[9], i[10], i[0], i[1]]), geom.Polygon([o[0], o[1], i[2], i[0], o[11]]), geom.Polygon([o[2], o[3], i[4], i[2], o[1]]), geom.Polygon([o[4], o[5], i[6], i[4], o[3]]), geom.Polygon([o[6], o[7], i[8], i[6], o[5]]), geom.Polygon([o[8], o[9], i[10], i[8], o[7]]), geom.Polygon([o[10], o[11], i[0], i[10], o[9]]) ] else: return [ geom.Polygon([c, inner[0], inner[1], inner[2]]), geom.Polygon([c, inner[2], inner[3], inner[4]]), geom.Polygon([c, inner[4], inner[5], inner[0]]), geom.Polygon([inner[1], inner[0], outer[0], outer[1]]), geom.Polygon([inner[2], inner[1], outer[1], outer[2]]), geom.Polygon([inner[3], inner[2], outer[2], outer[3]]), geom.Polygon([inner[4], inner[3], outer[3], outer[4]]), geom.Polygon([inner[5], inner[4], outer[4], outer[5]]), geom.Polygon([inner[0], inner[5], outer[5], outer[0]]) ]
def setup_archimedean(unit: TileUnit)
-
Expand source code
def setup_archimedean(unit:"TileUnit") -> None: """The Archimedean 'regular tilings. See https://en.wikipedia.org/wiki/List_of_Euclidean_uniform_tilings#Convex_uniform_tilings_of_the_Euclidean_plane Many of these are most easily constructed as duals of the Laves tilings. Some are not yet implemented: (3.3.3.4.4) is kind of weird (stripes of triangles and squares) so can't be bothered with it. Perhaps as a 5-variable option we'll get to it in time. Args: unit (TileUnit): the TileUnit to setup. """ if unit.code == "3.3.3.3.3.3": _setup_base_tile(unit, TileShape.TRIANGLE) _setup_none_tile(unit) return if unit.code == "3.3.3.3.6": setup_laves(unit) unit.tiles = tiling_utils.get_dual_tile_unit(unit) unit.setup_regularised_prototile_from_tiles() return elif unit.code == "3.3.3.4.4": print(f"The code [{unit.code}] is unsupported.") elif unit.code == "3.3.4.3.4": # this is an attractive 6-colourable triangles and squares tiling setup_laves(unit) unit.tiles = tiling_utils.get_dual_tile_unit(unit) unit.setup_regularised_prototile_from_tiles() return elif unit.code == "3.4.6.4": setup_laves(unit) unit.tiles = tiling_utils.get_dual_tile_unit(unit) unit.setup_regularised_prototile_from_tiles() return elif unit.code == "3.6.3.6": setup_laves(unit) unit.tiles = tiling_utils.get_dual_tile_unit(unit) unit.setup_regularised_prototile_from_tiles() return elif unit.code == "3.12.12": # nice! we can make dodecagons without having to think too hard # simply use the dual code. (Although really... it probably # would've been easier to make the dodecagon... other than # calculating the scale relative to the hexagon base tile!) setup_laves(unit) unit.setup_vectors() unit.tiles = tiling_utils.get_dual_tile_unit(unit) unit.setup_regularised_prototile_from_tiles() return elif unit.code == "4.4.4.4": _setup_base_tile(unit, TileShape.RECTANGLE) _setup_none_tile(unit) return elif unit.code == "4.6.12": # more dodecagons for free! setup_laves(unit) unit.setup_vectors() unit.tiles = tiling_utils.get_dual_tile_unit(unit) unit.setup_regularised_prototile_from_tiles() return elif unit.code == "4.8.8": # this is the octagon and square tiling setup_laves(unit) unit.tiles = tiling_utils.get_dual_tile_unit(unit) unit.setup_regularised_prototile_from_tiles() return elif unit.code == "6.6.6": _setup_base_tile(unit, TileShape.HEXAGON) _setup_none_tile(unit) return else: print(f"[{unit.code}] is not a valid Laves code.") unit.tiling_type = None _setup_none_tile(unit) return
The Archimedean 'regular tilings. See https://en.wikipedia.org/wiki/List_of_Euclidean_uniform_tilings#Convex_uniform_tilings_of_the_Euclidean_plane
Many of these are most easily constructed as duals of the Laves tilings.
Some are not yet implemented:
(3.3.3.4.4) is kind of weird (stripes of triangles and squares) so can't be bothered with it. Perhaps as a 5-variable option we'll get to it in time.
Args
unit
:TileUnit
- the TileUnit to setup.
def setup_cairo(unit: TileUnit)
-
Expand source code
def setup_cairo(unit:"TileUnit") -> None: """Sets up the Cairo tiling. King of tilings. All hail the Cairo tiling. This code shows how a 'handcoded' set of geometries can be applied. Note that it is advisable to avoid intersection and union operations where possible, as it often yields floating point mismatches that can be hard to repair! (This tiling can be relatively conveniently generated by dissecting a square in 4 quarters at a 30 degree angle to the sides and then reflecting and rotating copies and joing them back together. But floating point issues make that very messy indeed. Much better to make the geometries 'pure'.) Args: unit (TileUnit): the TileUnit to setup. """ _setup_base_tile(unit, TileShape.RECTANGLE) # a square d = unit.spacing x = d / 2 / (np.cos(np.radians(15)) + np.cos(np.radians(75))) # the following is just the geometry, it is what it is... # points are (more or less) # # 3 # 2 # 4 # 1 0 # # then rotate -15 and make 4 copies at 90 degree rotations p1 = geom.Polygon([(x, 0), (0, 0), (0, x), (x * np.sqrt(3) / 2, x + x / 2), (x * (1 + np.sqrt(3)) / 2, x * (3 - np.sqrt(3)) / 2)]) p1 = affine.rotate(p1, -15, (0, 0)) p2 = affine.rotate(p1, 90, (0, 0)) p3 = affine.rotate(p1, 180, (0, 0)) p4 = affine.rotate(p1, 270, (0, 0)) # now move them so they are arranged as a hexagon centered on the tile p1 = affine.translate(p1, -unit.spacing / 2, 0) p2 = affine.translate(p2, unit.spacing / 2, 0) p3 = affine.translate(p3, unit.spacing / 2, 0) p4 = affine.translate(p4, -unit.spacing / 2, 0) unit.tiles = gpd.GeoDataFrame( data = {"tile_id": list("abcd")}, crs = unit.crs, geometry = gpd.GeoSeries([p1, p2, p3, p4])) unit.setup_regularised_prototile_from_tiles()
Sets up the Cairo tiling. King of tilings. All hail the Cairo tiling. This code shows how a 'handcoded' set of geometries can be applied.
Note that it is advisable to avoid intersection and union operations where possible, as it often yields floating point mismatches that can be hard to repair! (This tiling can be relatively conveniently generated by dissecting a square in 4 quarters at a 30 degree angle to the sides and then reflecting and rotating copies and joing them back together. But floating point issues make that very messy indeed. Much better to make the geometries 'pure'.)
Args
unit
:TileUnit
- the TileUnit to setup.
def setup_hex_colouring(unit: TileUnit)
-
Expand source code
def setup_hex_colouring(unit:"TileUnit") -> None: """3, 4, and 7 colourings of a regular array of hexagons. Args: unit (TileUnit): the TileUnit to setup. """ hexagon = tiling_utils.get_regular_polygon(unit.spacing / np.sqrt(unit.n), 6) if unit.n == 3: # Point up hex at '*' displaced to 3 positions: # 2 # * # 3 1 _setup_base_tile(unit, TileShape.HEXAGON) hexagon = affine.rotate(hexagon, 30, origin = (0, 0)) # Copy and translate to alternate corners corners = [p for i, p in enumerate(hexagon.exterior.coords) if i in (0, 2, 4)] hexes = [affine.translate(hexagon, p[0], p[1]) for p in corners] elif unit.n == 4: # Point up hex at '*' displaced to 4 positions: # 2 # 3*1 # 4 _setup_base_tile(unit, TileShape.DIAMOND) hexagon = affine.rotate(hexagon, 30, origin = (0, 0)) hex1 = affine.translate(hexagon, unit.spacing / 4, 0) hex2 = affine.translate(hexagon, 0, unit.spacing * np.sqrt(3) / 4) hex3 = affine.translate(hexagon, -unit.spacing / 4, 0) hex4 = affine.translate(hexagon, 0, -unit.spacing * np.sqrt(3) / 4) hexes = [hex1, hex2, hex3, hex4] elif unit.n == 7: # the 'H3' tile # Make a hexagon and displace in the direction of its # own 6 corners, scaled as needed _setup_base_tile(unit, TileShape.HEXAGON) rotation = np.degrees(np.arctan(1 / 3 / np.sqrt(3))) corners = [p for p in hexagon.exterior.coords][:-1] hexagon = affine.rotate(hexagon, 30) hexes = [hexagon] + [affine.translate( hexagon, x * np.sqrt(3), y * np.sqrt(3)) for x, y in corners] hexes = [affine.rotate(h, rotation, origin = (0, 0)) for h in hexes] else: _setup_base_tile(unit, TileShape.HEXAGON) _setup_none_tile(unit) return unit.tiles = gpd.GeoDataFrame( data = {"tile_id": list(string.ascii_letters)[:unit.n]}, crs = unit.crs, geometry = gpd.GeoSeries(hexes)) unit.setup_regularised_prototile_from_tiles()
3, 4, and 7 colourings of a regular array of hexagons.
Args
unit
:TileUnit
- the TileUnit to setup.
def setup_hex_dissection(unit: TileUnit)
-
Expand source code
def setup_hex_dissection(unit:"TileUnit") -> None: """Tilings from dissection of a hexagon into parts. The supplied unit should have offset and n set. self.offset == 1 starts at midpoints, 0 at hexagon corners self.n is the number of slices and should be 2, 3, 4, 6 or 12. Args: unit (TileUnit): the TileUnit to setup. """ _setup_base_tile(unit, TileShape.HEXAGON) if unit.n == 4: parts = get_4_parts_of_hexagon(unit) elif unit.n == 7: parts = get_7_parts_of_hexagon(unit) elif unit.n == 9: parts = get_9_parts_of_hexagon(unit) unit.tiles = gpd.GeoDataFrame( data = {"tile_id": list(string.ascii_letters)[:unit.n]}, crs = unit.crs, geometry = gpd.GeoSeries(parts)) unit.regularised_prototile = copy.deepcopy(unit.prototile)
Tilings from dissection of a hexagon into parts.
The supplied unit should have offset and n set.
self.offset == 1 starts at midpoints, 0 at hexagon corners self.n is the number of slices and should be 2, 3, 4, 6 or 12.
Args
unit
:TileUnit
- the TileUnit to setup.
def setup_hex_slice(unit: TileUnit)
-
Expand source code
def setup_hex_slice(unit:"TileUnit") -> None: """Tilings from radial slices of a hexagon into 2, 3, 4, 6 or 12 slices. The supplied unit should have offset and n set. self.offset == 1 starts at midpoints, 0 at hexagon corners self.n is the number of slices and should be 2, 3, 4, 6 or 12. Again, construction avoids intersection operations where possible. Args: unit (TileUnit): the TileUnit to setup. """ _setup_base_tile(unit, TileShape.HEXAGON) hexagon = tiling_utils.get_regular_polygon(unit.spacing, 6) # note that shapely coords includes the first point at beginning # and end - very convenient! v = list(hexagon.exterior.coords) # midpoints m = [((p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2) for p1, p2 in zip(v[:-1], v[1:])] # chain into a list of corner, midpoint, corner, midpoint..., i.e. # # 6 5 4 # 7 3 # 8 2 # 9 1 # 10 11 0 # # then depending on number of slices and offset pick out points. # Do this explicitly not by rotation to avoid gaps. p = list(itertools.chain(*zip(v[:-1], m))) if unit.n == 2: slices = ( [geom.Polygon([p[0], p[2], p[4], p[6]]), geom.Polygon([p[6], p[8], p[10], p[0]])] if unit.offset == 0 else [geom.Polygon([p[1], p[2], p[4], p[6], p[7]]), geom.Polygon([p[7], p[8], p[10], p[0], p[1]])]) elif unit.n == 3: slices = ( [geom.Polygon([p[0], p[2], p[4], (0, 0)]), geom.Polygon([p[4], p[6], p[8], (0, 0)]), geom.Polygon([p[8], p[10], p[0], (0, 0)])] if unit.offset == 0 else [geom.Polygon([p[1], p[2], p[4], p[5], (0, 0)]), geom.Polygon([p[5], p[6], p[8], p[9], (0, 0)]), geom.Polygon([p[9], p[10], p[0], p[1], (0, 0)])]) elif unit.n == 4: slices = ( [geom.Polygon([p[0], p[2], p[3], (0, 0)]), geom.Polygon([p[3], p[4], p[6], (0, 0)]), geom.Polygon([p[6], p[8], p[9], (0, 0)]), geom.Polygon([p[9], p[10], p[0], (0, 0)])] if unit.offset == 0 else [geom.Polygon([p[1], p[2], p[4], (0, 0)]), geom.Polygon([p[4], p[6], p[7], (0, 0)]), geom.Polygon([p[7], p[8], p[10], (0, 0)]), geom.Polygon([p[10], p[0], p[1], (0, 0)])]) elif unit.n == 6: slices = ( [geom.Polygon([p[0], p[2], (0, 0)]), geom.Polygon([p[2], p[4], (0, 0)]), geom.Polygon([p[4], p[6], (0, 0)]), geom.Polygon([p[6], p[8], (0, 0)]), geom.Polygon([p[8], p[10], (0, 0)]), geom.Polygon([p[10], p[0], (0, 0)])] if unit.offset == 0 else [geom.Polygon([p[1], p[2], p[3], (0, 0)]), geom.Polygon([p[3], p[4], p[5], (0, 0)]), geom.Polygon([p[5], p[6], p[7], (0, 0)]), geom.Polygon([p[7], p[8], p[9], (0, 0)]), geom.Polygon([p[9], p[10], p[11], (0, 0)]), geom.Polygon([p[11], p[0], p[1], (0, 0)])]) elif unit.n == 12: if unit.offset == 0: ids = [i for i in range(12)] + [0] slices = [geom.Polygon([p[i], p[j], (0, 0)]) for i, j in zip(ids[:-1], ids[1:])] else: x = (np.sin(np.pi/6) - np.sin(np.pi/12)) / 6 base = [x, 1/6 - x] steps = base for i in range(1, 6): steps = steps + [x + i/6 for x in base] steps = [steps[-1] - 1] + steps slices = [tiling_utils.get_polygon_sector(hexagon, p1, p2) for p1, p2 in zip(steps[:-1], steps[1:])] unit.tiles = gpd.GeoDataFrame( data = {"tile_id": list(string.ascii_letters)[:unit.n]}, crs = unit.crs, geometry = gpd.GeoSeries(slices)) unit.regularised_prototile = copy.deepcopy(unit.prototile)
Tilings from radial slices of a hexagon into 2, 3, 4, 6 or 12 slices.
The supplied unit should have offset and n set.
self.offset == 1 starts at midpoints, 0 at hexagon corners self.n is the number of slices and should be 2, 3, 4, 6 or 12.
Again, construction avoids intersection operations where possible.
Args
unit
:TileUnit
- the TileUnit to setup.
def setup_laves(unit: TileUnit)
-
Expand source code
def setup_laves(unit:"TileUnit") -> None: """The Laves tilings. See https://en.wikipedia.org/wiki/List_of_Euclidean_uniform_tilings#Laves_tilings. These are all isohedral, but mostly not regular polygons. We prioritise them over the Archimedean tilings because being isohedral all tiles are the same size. Several are hex dissections and setup is delegated accordingly. Args: unit (TileUnit): the TileUnit to setup. """ if unit.code == "3.3.3.3.3.3": # this is the regular hexagons _setup_base_tile(unit, TileShape.HEXAGON) _setup_none_tile(unit) return if unit.code == "3.3.3.3.6": # this one needs its own code _setup_laves_33336(unit) return elif unit.code == "3.3.3.4.4": print(f"The code [{unit.code}] is unsupported.") elif unit.code == "3.3.4.3.4": # king of tilings! setup_cairo(unit) return elif unit.code == "3.4.6.4": # the hex 6-dissection unit.n = 6 unit.offset = 1 setup_hex_slice(unit) return elif unit.code == "3.6.3.6": # hex 3-dissection (also a cube weave!) unit.n = 3 unit.offset = 0 setup_hex_slice(unit) return elif unit.code == "3.12.12": # again this one needs its own _setup_laves_31212(unit) return elif unit.code == "4.4.4.4": # square grid _setup_base_tile(unit, TileShape.RECTANGLE) _setup_none_tile(unit) return elif unit.code == "4.6.12": # hex 12-dissection unit.n = 12 unit.offset = 0 setup_hex_slice(unit) return elif unit.code == "4.8.8": # this one needs its own (a 4-dissection of the square) # perhaps to be added as a category later... _setup_laves_488(unit) return elif unit.code == "6.6.6": # triangles _setup_base_tile(unit, TileShape.TRIANGLE) _setup_none_tile(unit) else: print(f"[{unit.code}] is not a valid Laves code.") unit.tiling_type = None _setup_none_tile(unit) return
The Laves tilings. See https://en.wikipedia.org/wiki/List_of_Euclidean_uniform_tilings#Laves_tilings.
These are all isohedral, but mostly not regular polygons. We prioritise them over the Archimedean tilings because being isohedral all tiles are the same size. Several are hex dissections and setup is delegated accordingly.
Args
unit
:TileUnit
- the TileUnit to setup.
def setup_square_colouring(unit: TileUnit)
-
Expand source code
def setup_square_colouring(unit:"TileUnit") -> None: """Colourings of a regular array of squares. Only supports n = 5 at present but we need a n=5 option Args: unit (TileUnit): the TileUnit to setup. """ sq = tiling_utils.get_regular_polygon(unit.spacing, 4) if unit.n == 5: _setup_base_tile(unit, TileShape.RECTANGLE) # Copy and translate square tr = [(0, 0), (unit.spacing, 0), (0, unit.spacing), (-unit.spacing, 0), (0, -unit.spacing)] squares = [affine.translate(sq, v[0], v[1]) for v in tr] squares = [affine.scale(sq, 1 / np.sqrt(5), 1 / np.sqrt(5), origin = (0, 0)) for sq in squares] rotation = np.degrees(np.arctan2(1, 2)) squares = [affine.rotate(sq, rotation, origin = (0, 0)) for sq in squares] else: _setup_base_tile(unit, TileShape.RECTANGLE) _setup_none_tile(unit) return unit.tiles = gpd.GeoDataFrame( data = {"tile_id": list(string.ascii_letters)[:unit.n]}, crs = unit.crs, geometry = gpd.GeoSeries(squares)) unit.setup_regularised_prototile_from_tiles()
Colourings of a regular array of squares. Only supports n = 5 at present but we need a n=5 option
Args
unit
:TileUnit
- the TileUnit to setup.