weavingspace.tile_unit
The TileUnit
subclass of weavingspace.tileable.Tileable
implements
many 'conventional' tilings of the plane.
Examples:
A TileUnit
is initialised like this
tile_unit = TileUnit(tiling_type = "cairo")
The tiling_type
may be one of the following
- "cairo" the Cairo tiling more formally known as the Laves [32.4.3.4] tiling. The author's favourite tiling, hence it has its own tiling_type.
- "hex-slice" a range of dissections of the regular hexagon into,
2, 3, 4, 6, or 12 'pie slices'. The number of slices is set by
specifying an additional argument
n
. Slices are cut either starting at the corners of the hexagon or from the midpoints of hexagon edges, by specifying an additional argumentoffset
set to either 0 or 1 respectively. - "hex-dissection" a range of 4, 7 or 9-fold dissections of the hexagon.
- "laves" a range of isohedral tilings. See this article.
The desired tiling is specified by the additional argument
code
which is a string like "3.3.4.3.4". - "archimedean" a range of tilings by regular polygons. See this
article. Many of these are the dual tilings of
the Laves tilings. The desired tiling is specified by the additional
argument
code
which is a string like "3.3.4.3.4". Not all the possible Archimedean tilings are implemented. - "hex-colouring" three colourings of the regular hexagon tiling, of
either 3, 4, or 7 colours, as specified by the argument
n
. "square-colouring" one colouring of the regular square tiling, of 5 colours as specified by the argument
n = 5
.See this notebook for exact usage, and illustrations of each tiling.
Spacing and coordinate reference of the tile unit are specified by the
weavingspace.tileable.Tileable
superclass variablesweavingspace.tileable.Tileable.spacing
andweavingspace.tileable.Tileable.crs
.Base tilings by squares, hexagons or triangles can also be requested using
tile_unit = TileUnit() # square tiling, the default tile_unit = TileUnit(tile_shape = TileShape.HEXAGON) tile_unit = TileUnit(tile_shape = TileShape.TRIANGLE)
The first two of these have only one tile_id value, and so cannot be used for multivariate mapping. The triangle case has two tile_id values so may be useful in its base form.
To create custom tilings start from one of the base tiles above, and explicitly set the
weavingspace.tileable.Tileable.tiles
variable by geometric construction of suitable shapely.geometry.Polygons. TODO: A detailed example of this usage can be found here ....
1#!/usr/bin/env python 2# coding: utf-8 3 4"""The `TileUnit` subclass of `weavingspace.tileable.Tileable` implements 5many 'conventional' tilings of the plane. 6 7Examples: 8 A `TileUnit` is initialised like this 9 10 tile_unit = TileUnit(tiling_type = "cairo") 11 12 The `tiling_type` may be one of the following 13 14 + "cairo" the Cairo tiling more formally known as the Laves 15 [3<sup>2</sup>.4.3.4] tiling. The author's favourite tiling, hence it 16 has its own tiling_type. 17 + "hex-slice" a range of dissections of the regular hexagon into, 18 2, 3, 4, 6, or 12 'pie slices'. The number of slices is set by 19 specifying an additional argument `n`. Slices are cut either starting 20 at the corners of the hexagon or from the midpoints of hexagon edges, 21 by specifying an additional argument `offset` set to either 22 0 or 1 respectively. 23 + "hex-dissection" a range of 4, 7 or 9-fold dissections of the hexagon. 24 + "laves" a range of isohedral tilings. See [this article](https://en.wikipedia.org/wiki/List_of_Euclidean_uniform_tilings#Laves_tilings). 25 The desired tiling is specified by the additional argument `code` which 26 is a string like "3.3.4.3.4". 27 + "archimedean" a range of tilings by regular polygons. See [this 28 article](https://en.wikipedia.org/wiki/Euclidean_tilings_by_convex_regular_polygons#Archimedean,_uniform_or_semiregular_tilings). Many of these are the dual tilings of 29 the Laves tilings. The desired tiling is specified by the additional 30 argument `code` which is a string like "3.3.4.3.4". Not all the 31 possible Archimedean tilings are implemented. 32 + "hex-colouring" three colourings of the regular hexagon tiling, of 33 either 3, 4, or 7 colours, as specified by the argument `n`. 34 + "square-colouring" one colouring of the regular square tiling, of 5 35 colours as specified by the argument `n = 5`. 36 37 See [this notebook](https://github.com/DOSull/weaving-space/blob/main/weavingspace/all-the-tiles.ipynb) for exact usage, and illustrations of 38 each tiling. 39 40 Spacing and coordinate reference of the tile unit are specified by the 41 `weavingspace.tileable.Tileable` superclass variables 42 `weavingspace.tileable.Tileable.spacing` and 43 `weavingspace.tileable.Tileable.crs`. 44 45 Base tilings by squares, hexagons or triangles can also be requested 46 using 47 48 tile_unit = TileUnit() # square tiling, the default 49 tile_unit = TileUnit(tile_shape = TileShape.HEXAGON) 50 tile_unit = TileUnit(tile_shape = TileShape.TRIANGLE) 51 52 The first two of these have only one tile_id value, and so cannot be 53 used for multivariate mapping. The triangle case has two tile_id 54 values so may be useful in its base form. 55 56 To create custom tilings start from one of the base tiles above, and 57 explicitly set the `weavingspace.tileable.Tileable.tiles` variable 58 by geometric construction of suitable shapely.geometry.Polygons. TODO: A detailed example of this usage can be found here .... 59""" 60 61import copy 62from dataclasses import dataclass 63from typing import Iterable 64import string 65 66import geopandas as gpd 67import numpy as np 68import shapely.geometry as geom 69import shapely.affinity as affine 70 71from weavingspace.tileable import Tileable 72from weavingspace.tileable import TileShape 73 74import weavingspace.tiling_utils as tiling_utils 75import weavingspace.tiling_geometries as tiling_geometries 76 77 78@dataclass 79class TileUnit(Tileable): 80 """Class to represent the tiles of a 'conventional' tiling. 81 """ 82 tiling_type:str = None 83 """tiling type as detailed in the class documentation preamble.""" 84 offset:int = 1 85 """offset for 'hex-dissection' and 'hex-slice tilings. Defaults to 1.""" 86 n:int = 3 87 """number of dissections or colours in 'hex-dissection', 'hex-slice' and 88 'hex-colouring' tilings. Defaults to 3.""" 89 code:str = "3.3.4.3.4" 90 """tne code for 'laves' or 'archimedean' tiling types.""" 91 92 def __init__(self, **kwargs) -> None: 93 super(TileUnit, self).__init__(**kwargs) 94 # this next line makes all TileUnit geometries shapely 2.x precision-aware 95 self.tiles.geometry = tiling_utils.gridify(self.tiles.geometry) 96 if not self.tiling_type is None: 97 self.tiling_type = self.tiling_type.lower() 98 if self.base_shape == TileShape.TRIANGLE: 99 self._modify_tile() 100 self._modify_tiles() 101 self.setup_vectors() 102 self.setup_regularised_prototile_from_tiles() 103 # if self.regularised_prototile is None: 104 # self.setup_regularised_prototile_from_tiles() 105 106 107 def _setup_tiles(self) -> None: 108 """Delegates setup of the unit to various functions depending 109 on self.tiling_type. 110 """ 111 if self.tiling_type == "cairo": 112 tiling_geometries.setup_cairo(self) 113 elif self.tiling_type == "hex-slice": 114 tiling_geometries.setup_hex_slice(self) 115 elif self.tiling_type == "hex-dissection": 116 tiling_geometries.setup_hex_dissection(self) 117 elif self.tiling_type == "laves": 118 tiling_geometries.setup_laves(self) 119 elif self.tiling_type == "archimedean": 120 tiling_geometries.setup_archimedean(self) 121 elif self.tiling_type in ("hex-colouring", "hex-coloring"): 122 tiling_geometries.setup_hex_colouring(self) 123 elif self.tiling_type in ("square-colouring", "square-coloring"): 124 tiling_geometries.setup_square_colouring(self) 125 else: 126 tiling_geometries._setup_none_tile(self) 127 return 128 129 130 def _setup_regularised_prototile(self) -> None: 131 self.regularise_tiles() 132 self.regularised_prototile.geometry = tiling_utils.repair_polygon( 133 self.regularised_prototile.geometry) 134 135 136 def _modify_tiles(self) -> None: 137 """It is not trivial to tile a triangle, so this function augments 138 the tiles of a triangular tile to a diamond by 180 degree 139 rotation. Operation is 'in place'. 140 """ 141 tiles = self.tiles.geometry 142 ids = list(self.tiles.tile_id) 143 144 new_ids = list(string.ascii_letters[:(len(ids) * 2)]) 145 tiles = tiles.translate(0, -tiles.total_bounds[1]) 146 twins = [affine.rotate(tile, a, origin = (0, 0)) 147 for tile in tiles 148 for a in range(0, 360, 180)] 149 self.tiles = gpd.GeoDataFrame( 150 data = {"tile_id": new_ids}, 151 geometry = tiling_utils.gridify(gpd.GeoSeries(twins)), 152 crs = self.tiles.crs) 153 return None 154 155 156 def _modify_tile(self) -> None: 157 """It is not trivial to tile a triangular tile so this function 158 changes the tile to a diamond by manually altering the tile in place 159 to be a diamond shape. 160 """ 161 tile = self.prototile.geometry[0] 162 # translate to sit on x-axis 163 tile = affine.translate(tile, 0, -tile.bounds[1]) 164 pts = [p for p in tile.exterior.coords] 165 pts[-1] = (pts[1][0], -pts[1][1]) 166 self.prototile.geometry = tiling_utils.gridify( 167 gpd.GeoSeries([geom.Polygon(pts)], crs = self.crs)) 168 self.base_shape = TileShape.DIAMOND 169 return None 170 171 172 def _get_legend_key_shapes(self, polygon:geom.Polygon, 173 counts:Iterable = [1] * 25, angle:float = 0, 174 radial:bool = False) -> list[geom.Polygon]: 175 """Returns a set of shapes that can be used to make a legend key 176 symbol for the supplied polygon. In TileUnit this is a set of 'nested' 177 polygons. 178 179 Args: 180 polygon (geom.Polygon): the polygon to symbolise. 181 count (Iterable, optional): iterable of the counts of each slice. 182 Defaults to [1] * 25. 183 rot (float, optional): rotation that may have to be applied. 184 Not used in the TileUnit case. Defaults to 0. 185 186 Returns: 187 list[geom.Polygon]: a list of nested polygons. 188 """ 189 if not radial: 190 n = sum(counts) 191 # bandwidths = list(np.cumsum(counts)) 192 bandwidths = [c / n for c in counts] 193 bandwidths = [bw if bw > 0.05 or bw == 0 else 0.05 194 for bw in bandwidths] 195 n = sum(bandwidths) 196 bandwidths = [0] + [bw / n for bw in bandwidths] 197 # # make buffer widths that will yield approx equal area 'annuli' 198 # bandwidths = range(n_steps + 1) 199 # sqrt exaggerates outermost annuli, which can otherwise disappear 200 bandwidths = [np.sqrt(bw) for bw in bandwidths] 201 distances = np.cumsum(bandwidths) 202 # get the negative buffer distance that will 'collapse' the polygon 203 radius = tiling_utils.get_collapse_distance(polygon) 204 distances = distances * radius / distances[-1] 205 nested_polys = [polygon.buffer(-d, join_style = 2, cap_style = 3) 206 for d in distances] 207 # return converted to annuli (who knows someone might set alpha < 1) 208 nested_polys = [g1.difference(g2) for g1, g2 in 209 zip(nested_polys[:-1], nested_polys[1:])] 210 return [p for c, p in zip(counts, nested_polys) if c > 0] 211 else: 212 n = sum(counts) 213 slice_posns = list(np.cumsum(counts)) 214 slice_posns = [0] + [p / n for p in slice_posns] 215 return [tiling_utils.get_polygon_sector(polygon, i, j) 216 for i, j in zip(slice_posns[:-1], slice_posns[1:])] 217 218 219 # Note that geopandas clip is not order preserving hence we do this 220 # one polygon at a time... 221 def inset_prototile(self, d:float = 0) -> "TileUnit": 222 """Returns a new TileUnit clipped by `self.regularised_tile` after 223 a negative buffer d has been applied. 224 225 Args: 226 d (float, optional): the inset distance. Defaults to 0. 227 228 Returns: 229 TileUnit: the new TileUnit with inset applied. 230 """ 231 inset_tile = \ 232 self.regularised_prototile.geometry.buffer(-d, join_style = 2, cap_style = 3)[0] 233 new_tiles = [inset_tile.intersection(e) for e in self.tiles.geometry] 234 result = copy.deepcopy(self) 235 result.tiles.geometry = gpd.GeoSeries(new_tiles) 236 return result 237 238 239 def scale_tiles(self, sf:float = 1) -> "TileUnit": 240 """Scales the tiles by the specified factor, centred on (0, 0). 241 242 Args: 243 sf (float, optional): scale factor to apply. Defaults to 1. 244 245 Returns: 246 TileUnit: the scaled TileUnit. 247 """ 248 result = copy.deepcopy(self) 249 result.tiles.geometry = tiling_utils.gridify( 250 self.tiles.geometry.scale(sf, sf, origin = (0, 0))) 251 return result
79@dataclass 80class TileUnit(Tileable): 81 """Class to represent the tiles of a 'conventional' tiling. 82 """ 83 tiling_type:str = None 84 """tiling type as detailed in the class documentation preamble.""" 85 offset:int = 1 86 """offset for 'hex-dissection' and 'hex-slice tilings. Defaults to 1.""" 87 n:int = 3 88 """number of dissections or colours in 'hex-dissection', 'hex-slice' and 89 'hex-colouring' tilings. Defaults to 3.""" 90 code:str = "3.3.4.3.4" 91 """tne code for 'laves' or 'archimedean' tiling types.""" 92 93 def __init__(self, **kwargs) -> None: 94 super(TileUnit, self).__init__(**kwargs) 95 # this next line makes all TileUnit geometries shapely 2.x precision-aware 96 self.tiles.geometry = tiling_utils.gridify(self.tiles.geometry) 97 if not self.tiling_type is None: 98 self.tiling_type = self.tiling_type.lower() 99 if self.base_shape == TileShape.TRIANGLE: 100 self._modify_tile() 101 self._modify_tiles() 102 self.setup_vectors() 103 self.setup_regularised_prototile_from_tiles() 104 # if self.regularised_prototile is None: 105 # self.setup_regularised_prototile_from_tiles() 106 107 108 def _setup_tiles(self) -> None: 109 """Delegates setup of the unit to various functions depending 110 on self.tiling_type. 111 """ 112 if self.tiling_type == "cairo": 113 tiling_geometries.setup_cairo(self) 114 elif self.tiling_type == "hex-slice": 115 tiling_geometries.setup_hex_slice(self) 116 elif self.tiling_type == "hex-dissection": 117 tiling_geometries.setup_hex_dissection(self) 118 elif self.tiling_type == "laves": 119 tiling_geometries.setup_laves(self) 120 elif self.tiling_type == "archimedean": 121 tiling_geometries.setup_archimedean(self) 122 elif self.tiling_type in ("hex-colouring", "hex-coloring"): 123 tiling_geometries.setup_hex_colouring(self) 124 elif self.tiling_type in ("square-colouring", "square-coloring"): 125 tiling_geometries.setup_square_colouring(self) 126 else: 127 tiling_geometries._setup_none_tile(self) 128 return 129 130 131 def _setup_regularised_prototile(self) -> None: 132 self.regularise_tiles() 133 self.regularised_prototile.geometry = tiling_utils.repair_polygon( 134 self.regularised_prototile.geometry) 135 136 137 def _modify_tiles(self) -> None: 138 """It is not trivial to tile a triangle, so this function augments 139 the tiles of a triangular tile to a diamond by 180 degree 140 rotation. Operation is 'in place'. 141 """ 142 tiles = self.tiles.geometry 143 ids = list(self.tiles.tile_id) 144 145 new_ids = list(string.ascii_letters[:(len(ids) * 2)]) 146 tiles = tiles.translate(0, -tiles.total_bounds[1]) 147 twins = [affine.rotate(tile, a, origin = (0, 0)) 148 for tile in tiles 149 for a in range(0, 360, 180)] 150 self.tiles = gpd.GeoDataFrame( 151 data = {"tile_id": new_ids}, 152 geometry = tiling_utils.gridify(gpd.GeoSeries(twins)), 153 crs = self.tiles.crs) 154 return None 155 156 157 def _modify_tile(self) -> None: 158 """It is not trivial to tile a triangular tile so this function 159 changes the tile to a diamond by manually altering the tile in place 160 to be a diamond shape. 161 """ 162 tile = self.prototile.geometry[0] 163 # translate to sit on x-axis 164 tile = affine.translate(tile, 0, -tile.bounds[1]) 165 pts = [p for p in tile.exterior.coords] 166 pts[-1] = (pts[1][0], -pts[1][1]) 167 self.prototile.geometry = tiling_utils.gridify( 168 gpd.GeoSeries([geom.Polygon(pts)], crs = self.crs)) 169 self.base_shape = TileShape.DIAMOND 170 return None 171 172 173 def _get_legend_key_shapes(self, polygon:geom.Polygon, 174 counts:Iterable = [1] * 25, angle:float = 0, 175 radial:bool = False) -> list[geom.Polygon]: 176 """Returns a set of shapes that can be used to make a legend key 177 symbol for the supplied polygon. In TileUnit this is a set of 'nested' 178 polygons. 179 180 Args: 181 polygon (geom.Polygon): the polygon to symbolise. 182 count (Iterable, optional): iterable of the counts of each slice. 183 Defaults to [1] * 25. 184 rot (float, optional): rotation that may have to be applied. 185 Not used in the TileUnit case. Defaults to 0. 186 187 Returns: 188 list[geom.Polygon]: a list of nested polygons. 189 """ 190 if not radial: 191 n = sum(counts) 192 # bandwidths = list(np.cumsum(counts)) 193 bandwidths = [c / n for c in counts] 194 bandwidths = [bw if bw > 0.05 or bw == 0 else 0.05 195 for bw in bandwidths] 196 n = sum(bandwidths) 197 bandwidths = [0] + [bw / n for bw in bandwidths] 198 # # make buffer widths that will yield approx equal area 'annuli' 199 # bandwidths = range(n_steps + 1) 200 # sqrt exaggerates outermost annuli, which can otherwise disappear 201 bandwidths = [np.sqrt(bw) for bw in bandwidths] 202 distances = np.cumsum(bandwidths) 203 # get the negative buffer distance that will 'collapse' the polygon 204 radius = tiling_utils.get_collapse_distance(polygon) 205 distances = distances * radius / distances[-1] 206 nested_polys = [polygon.buffer(-d, join_style = 2, cap_style = 3) 207 for d in distances] 208 # return converted to annuli (who knows someone might set alpha < 1) 209 nested_polys = [g1.difference(g2) for g1, g2 in 210 zip(nested_polys[:-1], nested_polys[1:])] 211 return [p for c, p in zip(counts, nested_polys) if c > 0] 212 else: 213 n = sum(counts) 214 slice_posns = list(np.cumsum(counts)) 215 slice_posns = [0] + [p / n for p in slice_posns] 216 return [tiling_utils.get_polygon_sector(polygon, i, j) 217 for i, j in zip(slice_posns[:-1], slice_posns[1:])] 218 219 220 # Note that geopandas clip is not order preserving hence we do this 221 # one polygon at a time... 222 def inset_prototile(self, d:float = 0) -> "TileUnit": 223 """Returns a new TileUnit clipped by `self.regularised_tile` after 224 a negative buffer d has been applied. 225 226 Args: 227 d (float, optional): the inset distance. Defaults to 0. 228 229 Returns: 230 TileUnit: the new TileUnit with inset applied. 231 """ 232 inset_tile = \ 233 self.regularised_prototile.geometry.buffer(-d, join_style = 2, cap_style = 3)[0] 234 new_tiles = [inset_tile.intersection(e) for e in self.tiles.geometry] 235 result = copy.deepcopy(self) 236 result.tiles.geometry = gpd.GeoSeries(new_tiles) 237 return result 238 239 240 def scale_tiles(self, sf:float = 1) -> "TileUnit": 241 """Scales the tiles by the specified factor, centred on (0, 0). 242 243 Args: 244 sf (float, optional): scale factor to apply. Defaults to 1. 245 246 Returns: 247 TileUnit: the scaled TileUnit. 248 """ 249 result = copy.deepcopy(self) 250 result.tiles.geometry = tiling_utils.gridify( 251 self.tiles.geometry.scale(sf, sf, origin = (0, 0))) 252 return result
Class to represent the tiles of a 'conventional' tiling.
93 def __init__(self, **kwargs) -> None: 94 super(TileUnit, self).__init__(**kwargs) 95 # this next line makes all TileUnit geometries shapely 2.x precision-aware 96 self.tiles.geometry = tiling_utils.gridify(self.tiles.geometry) 97 if not self.tiling_type is None: 98 self.tiling_type = self.tiling_type.lower() 99 if self.base_shape == TileShape.TRIANGLE: 100 self._modify_tile() 101 self._modify_tiles() 102 self.setup_vectors() 103 self.setup_regularised_prototile_from_tiles() 104 # if self.regularised_prototile is None: 105 # self.setup_regularised_prototile_from_tiles()
number of dissections or colours in 'hex-dissection', 'hex-slice' and 'hex-colouring' tilings. Defaults to 3.
222 def inset_prototile(self, d:float = 0) -> "TileUnit": 223 """Returns a new TileUnit clipped by `self.regularised_tile` after 224 a negative buffer d has been applied. 225 226 Args: 227 d (float, optional): the inset distance. Defaults to 0. 228 229 Returns: 230 TileUnit: the new TileUnit with inset applied. 231 """ 232 inset_tile = \ 233 self.regularised_prototile.geometry.buffer(-d, join_style = 2, cap_style = 3)[0] 234 new_tiles = [inset_tile.intersection(e) for e in self.tiles.geometry] 235 result = copy.deepcopy(self) 236 result.tiles.geometry = gpd.GeoSeries(new_tiles) 237 return result
Returns a new TileUnit clipped by self.regularised_tile
after
a negative buffer d has been applied.
Args: d (float, optional): the inset distance. Defaults to 0.
Returns: TileUnit: the new TileUnit with inset applied.
240 def scale_tiles(self, sf:float = 1) -> "TileUnit": 241 """Scales the tiles by the specified factor, centred on (0, 0). 242 243 Args: 244 sf (float, optional): scale factor to apply. Defaults to 1. 245 246 Returns: 247 TileUnit: the scaled TileUnit. 248 """ 249 result = copy.deepcopy(self) 250 result.tiles.geometry = tiling_utils.gridify( 251 self.tiles.geometry.scale(sf, sf, origin = (0, 0))) 252 return result
Scales the tiles by the specified factor, centred on (0, 0).
Args: sf (float, optional): scale factor to apply. Defaults to 1.
Returns: TileUnit: the scaled TileUnit.
Inherited Members
- weavingspace.tileable.Tileable
- tiles
- prototile
- spacing
- base_shape
- vectors
- regularised_prototile
- crs
- rotation
- debug
- setup_vectors
- get_vectors
- setup_regularised_prototile_from_tiles
- merge_fragments
- reattach_tiles
- regularise_tiles
- get_local_patch
- fit_tiles_to_prototile
- inset_tiles
- plot
- transform_scale
- transform_rotate
- transform_skew