weavingspace.tile_unit
TileUnit subclass of weavingspace.tileable.Tileable.
Implements many 'conventional' tilings of the plane.
Examples:
A
TileUnitis initialised like thistile_unit = TileUnit(tiling_type = "cairo")
The
tiling_typemay 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 argumentoffsetset 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 https://en.wikipedia.org/wiki/List_of_Euclidean_uniform_tilings. The desired tiling is specified by the additional argument
codewhich is a string like "3.3.4.3.4". Not all the possible Laves tilings are implemented.- "archimedean" a range of tilings by regular polygons. See https://en.wikipedia.org/wiki/Euclidean_tilings_by_convex_regular_polygons. Many of these are the dual tilings of the Laves tilings. The desired tiling is specified by the additional argument
codewhich is a string like "3.3.4.3.4". Not all the possible Archimedean tilings are implemented.- "hex-colouring" or "square-colouring" colourings of the regular hexagonal and square tilings of between 2 and 10 colours, as specified by the argument
n.- "hex-slice" or "square-slice" dissections of the regular hexagonal and square tilings of between 2 and 12 colours, as specified by the arguments
nandoffset.- "crosses" colourings of cross-shaped pentominoes of between
Spacing and coordinate reference of the tile unit are specified by the
weavingspace.tileable.Tileablesuperclass variablesweavingspace.tileable.Tileable.spacingandweavingspace.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.tilesvariable by geometric construction of suitable shapely.geometry.Polygons. TODO: A detailed example of this usage can be found here ....
1"""`TileUnit` subclass of `weavingspace.tileable.Tileable`. 2 3Implements many 'conventional' tilings of the plane. 4 5Examples: 6 A `TileUnit` is initialised like this 7 8 tile_unit = TileUnit(tiling_type = "cairo") 9 10 The `tiling_type` may be one of the following 11 12 + "cairo" the Cairo tiling more formally known as the Laves 13 [3<sup>2</sup>.4.3.4] tiling. The author's favourite tiling, hence it has its 14 own tiling_type. 15 + "hex-slice" a range of dissections of the regular hexagon into, 2, 3, 4, 6, 16 or 12 'pie slices'. The number of slices is set by specifying an additional 17 argument `n`. Slices are cut either starting at the corners of the hexagon 18 or from the midpoints of hexagon edges, by specifying an additional argument 19 `offset` set to either 0 or 1 respectively. 20 + "hex-dissection" a range of 4, 7 or 9-fold dissections of the hexagon. 21 + "laves" a range of isohedral tilings. See 22 https://en.wikipedia.org/wiki/List_of_Euclidean_uniform_tilings. 23 The desired tiling is specified by the additional argument `code` which is a 24 string like "3.3.4.3.4". Not all the possible Laves tilings are implemented. 25 + "archimedean" a range of tilings by regular polygons. See 26 https://en.wikipedia.org/wiki/Euclidean_tilings_by_convex_regular_polygons. 27 Many of these are the dual tilings of the Laves tilings. The desired tiling 28 is specified by the additional argument `code` which is a string like 29 "3.3.4.3.4". Not all the possible Archimedean tilings are implemented. 30 + "hex-colouring" or "square-colouring" colourings of the regular hexagonal 31 and square tilings of between 2 and 10 colours, as specified by the argument 32 `n`. 33 + "hex-slice" or "square-slice" dissections of the regular hexagonal and 34 square tilings of between 2 and 12 colours, as specified by the arguments `n` 35 and `offset`. 36 + "crosses" colourings of cross-shaped pentominoes of between 37 38 Spacing and coordinate reference of the tile unit are specified by the 39 `weavingspace.tileable.Tileable` superclass variables 40 `weavingspace.tileable.Tileable.spacing` and 41 `weavingspace.tileable.Tileable.crs`. 42 43 Base tilings by squares, hexagons or triangles can also be requested 44 using 45 46 tile_unit = TileUnit() # square tiling, the default 47 tile_unit = TileUnit(tile_shape = TileShape.HEXAGON) 48 tile_unit = TileUnit(tile_shape = TileShape.TRIANGLE) 49 50 The first two of these have only one tile_id value, and so cannot be 51 used for multivariate mapping. The triangle case has two tile_id 52 values so may be useful in its base form. 53 54 To create custom tilings start from one of the base tiles above, and 55 explicitly set the `weavingspace.tileable.Tileable.tiles` variable 56 by geometric construction of suitable shapely.geometry.Polygons. 57 TODO: A detailed example of this usage can be found here .... 58 59""" 60 61from __future__ import annotations 62 63import copy 64from dataclasses import dataclass 65from typing import TYPE_CHECKING 66 67import geopandas as gpd 68import numpy as np 69import shapely.geometry as geom 70 71from weavingspace import Tileable, tiling_utils 72from weavingspace import _tiling_geometries as geometries 73 74if TYPE_CHECKING: 75 from collections.abc import Iterable 76 77 78@dataclass 79class TileUnit(Tileable): 80 """Class to represent the tiles of a 'conventional' tiling. 81 82 Most of the functionality of TileUnit is either in `Tileable` superclass 83 or, for setup, in the functions in `tiling_geometries`. 84 """ 85 86 tiling_type:str = "" 87 """tiling type as detailed in the class documentation preamble.""" 88 offset:float = 1 89 """offset for 'dissection' and 'slice' tilings. Defaults to 1.""" 90 n:int = 3 91 """number of dissections or colours in 'hex-dissection', 'hex-slice' and 92 'hex-colouring' tilings. Defaults to 3.""" 93 code:str = "3.3.4.3.4" 94 """the code for 'laves' or 'archimedean' tiling types.""" 95 96 def __init__(self, **kwargs: str) -> None: 97 """Delegate construction to superclass.""" 98 # pass the kwargs to the superclass constructor 99 # it will delegate setting up the tiles back to `TileUnit._setup_tiles()` 100 super().__init__(**kwargs) 101 102 103 def _setup_tiles(self) -> None|str: 104 """Delegate setup of unit to functions in `tiling_geometries`. 105 106 Depending on `self.tiling_type` different function are called. If there is 107 a problem a string is returned. If all is OK then None is returned (some 108 care is required to do this!) 109 110 The logical tests applied to check for tiling type are expansive to allow 111 scope for user errors. 112 113 Returns: 114 str|None: if a problem occurs a message string, otherwise None. 115 116 """ 117 match self.tiling_type: 118 case "cairo": 119 return geometries._setup_cairo(self) 120 case x if "hex" in x and "slice" in x: 121 return geometries._setup_hex_slice(self) 122 case x if "hex" in x and "dissect" in x: 123 return geometries._setup_hex_dissection(self) 124 case x if "hex" in x and "col" in x: 125 return geometries._setup_hex_colouring(self) 126 case x if "square" in x and "slice" in x: 127 return geometries._setup_square_slice(self) 128 case x if "square" in x and "dissect" in x: 129 return geometries._setup_square_dissection(self) 130 case x if "square" in x and "col" in x: 131 return geometries._setup_square_colouring(self) 132 case x if "grid" in x: 133 return geometries._setup_grid(self) 134 case x if "stripe" in x: 135 return geometries._setup_stripes(self) 136 case x if "lave" in x: 137 return geometries._setup_laves(self) 138 case x if "archi" in x: 139 return geometries._setup_archimedean(self) 140 case x if "star1" in x: 141 return geometries._setup_star_polygon_1(self) 142 case x if "star2" in x: 143 return geometries._setup_star_polygon_2(self) 144 case x if "chavey" in x: 145 return geometries._setup_chavey(self) 146 case x if "cross" in x: 147 return geometries._setup_crosses(self) 148 case _: 149 return geometries._setup_base_tiling(self) 150 151 152 def _setup_regularised_prototile( 153 self, 154 override:bool = False, 155 ) -> None: 156 """Set up a 'regularised prototile' which contains all tile elements. 157 158 The regularised prototile does not cut the tile elements. In all TileUnit 159 cases a suitable shape is the union of the elements. 160 """ 161 # For whatever reasons in the web app version the unioning operation as 162 # operated by tiling_utils.safe_union() is anything but and produces 163 # TopologyException crashes... so here is a safe_union avoidant way... 164 if self.regularised_prototile is None or override: 165 tiles = copy.deepcopy(self.tiles.geometry) 166 tiles = gpd.GeoSeries( 167 [tiling_utils.gridify(p.buffer( 168 tiling_utils.RESOLUTION * 10, join_style=2, cap_style=3)) 169 for p in tiles]) 170 self.regularised_prototile = gpd.GeoDataFrame( 171 geometry = gpd.GeoSeries( 172 [tiles.union_all().buffer( 173 -tiling_utils.RESOLUTION * 10, 174 join_style = "mitre", cap_style = "square").simplify( 175 tiling_utils.RESOLUTION * 10)]), 176 crs = self.crs) 177 178 179 def _get_legend_key_shapes( 180 self, 181 polygon:geom.Polygon, 182 counts:Iterable = [1] * 25, 183 angle:float = 0, 184 radial:bool = False, 185 ) -> list[geom.Polygon]: 186 """Return a set of shapes useable as a legend key for the supplied polygon. 187 188 In TileUnit this is a set of 'nested' polygons formed 189 by serially negative buffering the tile shapes. 190 191 Args: 192 polygon (geom.Polygon): the polygon to symbolise. 193 counts (Iterable, optional): iterable of the counts of each slice. 194 Defaults to [1] * 25. 195 angle (float, optional): rotation that may have to be applied. Not used in 196 the TileUnit case. Defaults to 0. 197 radial (bool): if True then a pie slice dissection will be applied; if 198 False then a set of 'nested' shapes will be applied. Defaults to False. 199 200 Returns: 201 list[geom.Polygon]: a list of nested polygons. 202 203 """ 204 if radial: 205 n = sum(counts) 206 slice_posns = list(np.cumsum(counts)) 207 slice_posns = [0] + [p / n for p in slice_posns] 208 return [tiling_utils.get_polygon_sector(polygon, i, j) 209 for i, j in zip(slice_posns[:-1], slice_posns[1:], strict = True)] 210 n = sum(counts) 211 bandwidths = [c / n for c in counts] 212 bandwidths = [bw if bw > 0.05 or bw == 0 else 0.05 213 for bw in bandwidths] 214 n = sum(bandwidths) 215 bandwidths = [0] + [bw / n for bw in bandwidths] 216 # sqrt exaggerates outermost annuli, which can otherwise disappear 217 bandwidths = [np.sqrt(bw) for bw in bandwidths] 218 distances = np.cumsum(bandwidths) 219 # get the negative buffer distance that will 'collapse' the polygon 220 radius = tiling_utils.get_apothem(polygon) 221 distances = distances * radius / distances[-1] 222 nested_polys = [ 223 polygon.buffer(-d, join_style = "mitre", cap_style = "square") 224 for d in distances] 225 # DON'T CONVERT TO ANNULI - it washes the final colours out in rendering 226 return [p for c, p in zip(counts, nested_polys) if c > 0] # noqa: B905 227 228 229 def inset_prototile(self, d:float = 0) -> TileUnit: 230 """Return new TileUnit clipped by negatively buffered regularised_prototile. 231 232 Note that geopandas clip is not order preserving hence we do this one 233 polygon at a time. 234 235 Args: 236 d (float, optional): the inset distance. Defaults to 0. 237 238 Returns: 239 TileUnit: the new TileUnit with inset applied. 240 241 """ 242 if d == 0: 243 return self 244 inset_tile:gpd.GeoSeries = \ 245 self.regularised_prototile.loc[0, "geometry"] \ 246 .buffer(-d, join_style = "mitre", cap_style = "square") 247 new_tiles = [tiling_utils.get_clean_polygon(inset_tile.intersection(e)) 248 for e in self.tiles.geometry] 249 result = copy.deepcopy(self) 250 result.tiles.geometry = gpd.GeoSeries(new_tiles) 251 return result 252 253 254 def _as_circles(self) -> TileUnit: 255 """Experimental implementation of returning tiles as their incircles. 256 257 Returns: 258 TileUnit: a tiling which replaces each tile with its incircle. 259 260 """ 261 result = copy.deepcopy(self) 262 result.tiles.geometry = gpd.GeoSeries([tiling_utils.get_incircle(p) 263 for p in self.tiles.geometry]) 264 return result
79@dataclass 80class TileUnit(Tileable): 81 """Class to represent the tiles of a 'conventional' tiling. 82 83 Most of the functionality of TileUnit is either in `Tileable` superclass 84 or, for setup, in the functions in `tiling_geometries`. 85 """ 86 87 tiling_type:str = "" 88 """tiling type as detailed in the class documentation preamble.""" 89 offset:float = 1 90 """offset for 'dissection' and 'slice' tilings. Defaults to 1.""" 91 n:int = 3 92 """number of dissections or colours in 'hex-dissection', 'hex-slice' and 93 'hex-colouring' tilings. Defaults to 3.""" 94 code:str = "3.3.4.3.4" 95 """the code for 'laves' or 'archimedean' tiling types.""" 96 97 def __init__(self, **kwargs: str) -> None: 98 """Delegate construction to superclass.""" 99 # pass the kwargs to the superclass constructor 100 # it will delegate setting up the tiles back to `TileUnit._setup_tiles()` 101 super().__init__(**kwargs) 102 103 104 def _setup_tiles(self) -> None|str: 105 """Delegate setup of unit to functions in `tiling_geometries`. 106 107 Depending on `self.tiling_type` different function are called. If there is 108 a problem a string is returned. If all is OK then None is returned (some 109 care is required to do this!) 110 111 The logical tests applied to check for tiling type are expansive to allow 112 scope for user errors. 113 114 Returns: 115 str|None: if a problem occurs a message string, otherwise None. 116 117 """ 118 match self.tiling_type: 119 case "cairo": 120 return geometries._setup_cairo(self) 121 case x if "hex" in x and "slice" in x: 122 return geometries._setup_hex_slice(self) 123 case x if "hex" in x and "dissect" in x: 124 return geometries._setup_hex_dissection(self) 125 case x if "hex" in x and "col" in x: 126 return geometries._setup_hex_colouring(self) 127 case x if "square" in x and "slice" in x: 128 return geometries._setup_square_slice(self) 129 case x if "square" in x and "dissect" in x: 130 return geometries._setup_square_dissection(self) 131 case x if "square" in x and "col" in x: 132 return geometries._setup_square_colouring(self) 133 case x if "grid" in x: 134 return geometries._setup_grid(self) 135 case x if "stripe" in x: 136 return geometries._setup_stripes(self) 137 case x if "lave" in x: 138 return geometries._setup_laves(self) 139 case x if "archi" in x: 140 return geometries._setup_archimedean(self) 141 case x if "star1" in x: 142 return geometries._setup_star_polygon_1(self) 143 case x if "star2" in x: 144 return geometries._setup_star_polygon_2(self) 145 case x if "chavey" in x: 146 return geometries._setup_chavey(self) 147 case x if "cross" in x: 148 return geometries._setup_crosses(self) 149 case _: 150 return geometries._setup_base_tiling(self) 151 152 153 def _setup_regularised_prototile( 154 self, 155 override:bool = False, 156 ) -> None: 157 """Set up a 'regularised prototile' which contains all tile elements. 158 159 The regularised prototile does not cut the tile elements. In all TileUnit 160 cases a suitable shape is the union of the elements. 161 """ 162 # For whatever reasons in the web app version the unioning operation as 163 # operated by tiling_utils.safe_union() is anything but and produces 164 # TopologyException crashes... so here is a safe_union avoidant way... 165 if self.regularised_prototile is None or override: 166 tiles = copy.deepcopy(self.tiles.geometry) 167 tiles = gpd.GeoSeries( 168 [tiling_utils.gridify(p.buffer( 169 tiling_utils.RESOLUTION * 10, join_style=2, cap_style=3)) 170 for p in tiles]) 171 self.regularised_prototile = gpd.GeoDataFrame( 172 geometry = gpd.GeoSeries( 173 [tiles.union_all().buffer( 174 -tiling_utils.RESOLUTION * 10, 175 join_style = "mitre", cap_style = "square").simplify( 176 tiling_utils.RESOLUTION * 10)]), 177 crs = self.crs) 178 179 180 def _get_legend_key_shapes( 181 self, 182 polygon:geom.Polygon, 183 counts:Iterable = [1] * 25, 184 angle:float = 0, 185 radial:bool = False, 186 ) -> list[geom.Polygon]: 187 """Return a set of shapes useable as a legend key for the supplied polygon. 188 189 In TileUnit this is a set of 'nested' polygons formed 190 by serially negative buffering the tile shapes. 191 192 Args: 193 polygon (geom.Polygon): the polygon to symbolise. 194 counts (Iterable, optional): iterable of the counts of each slice. 195 Defaults to [1] * 25. 196 angle (float, optional): rotation that may have to be applied. Not used in 197 the TileUnit case. Defaults to 0. 198 radial (bool): if True then a pie slice dissection will be applied; if 199 False then a set of 'nested' shapes will be applied. Defaults to False. 200 201 Returns: 202 list[geom.Polygon]: a list of nested polygons. 203 204 """ 205 if radial: 206 n = sum(counts) 207 slice_posns = list(np.cumsum(counts)) 208 slice_posns = [0] + [p / n for p in slice_posns] 209 return [tiling_utils.get_polygon_sector(polygon, i, j) 210 for i, j in zip(slice_posns[:-1], slice_posns[1:], strict = True)] 211 n = sum(counts) 212 bandwidths = [c / n for c in counts] 213 bandwidths = [bw if bw > 0.05 or bw == 0 else 0.05 214 for bw in bandwidths] 215 n = sum(bandwidths) 216 bandwidths = [0] + [bw / n for bw in bandwidths] 217 # sqrt exaggerates outermost annuli, which can otherwise disappear 218 bandwidths = [np.sqrt(bw) for bw in bandwidths] 219 distances = np.cumsum(bandwidths) 220 # get the negative buffer distance that will 'collapse' the polygon 221 radius = tiling_utils.get_apothem(polygon) 222 distances = distances * radius / distances[-1] 223 nested_polys = [ 224 polygon.buffer(-d, join_style = "mitre", cap_style = "square") 225 for d in distances] 226 # DON'T CONVERT TO ANNULI - it washes the final colours out in rendering 227 return [p for c, p in zip(counts, nested_polys) if c > 0] # noqa: B905 228 229 230 def inset_prototile(self, d:float = 0) -> TileUnit: 231 """Return new TileUnit clipped by negatively buffered regularised_prototile. 232 233 Note that geopandas clip is not order preserving hence we do this one 234 polygon at a time. 235 236 Args: 237 d (float, optional): the inset distance. Defaults to 0. 238 239 Returns: 240 TileUnit: the new TileUnit with inset applied. 241 242 """ 243 if d == 0: 244 return self 245 inset_tile:gpd.GeoSeries = \ 246 self.regularised_prototile.loc[0, "geometry"] \ 247 .buffer(-d, join_style = "mitre", cap_style = "square") 248 new_tiles = [tiling_utils.get_clean_polygon(inset_tile.intersection(e)) 249 for e in self.tiles.geometry] 250 result = copy.deepcopy(self) 251 result.tiles.geometry = gpd.GeoSeries(new_tiles) 252 return result 253 254 255 def _as_circles(self) -> TileUnit: 256 """Experimental implementation of returning tiles as their incircles. 257 258 Returns: 259 TileUnit: a tiling which replaces each tile with its incircle. 260 261 """ 262 result = copy.deepcopy(self) 263 result.tiles.geometry = gpd.GeoSeries([tiling_utils.get_incircle(p) 264 for p in self.tiles.geometry]) 265 return result
Class to represent the tiles of a 'conventional' tiling.
Most of the functionality of TileUnit is either in Tileable superclass
or, for setup, in the functions in tiling_geometries.
97 def __init__(self, **kwargs: str) -> None: 98 """Delegate construction to superclass.""" 99 # pass the kwargs to the superclass constructor 100 # it will delegate setting up the tiles back to `TileUnit._setup_tiles()` 101 super().__init__(**kwargs)
Delegate construction to superclass.
number of dissections or colours in 'hex-dissection', 'hex-slice' and 'hex-colouring' tilings. Defaults to 3.
230 def inset_prototile(self, d:float = 0) -> TileUnit: 231 """Return new TileUnit clipped by negatively buffered regularised_prototile. 232 233 Note that geopandas clip is not order preserving hence we do this one 234 polygon at a time. 235 236 Args: 237 d (float, optional): the inset distance. Defaults to 0. 238 239 Returns: 240 TileUnit: the new TileUnit with inset applied. 241 242 """ 243 if d == 0: 244 return self 245 inset_tile:gpd.GeoSeries = \ 246 self.regularised_prototile.loc[0, "geometry"] \ 247 .buffer(-d, join_style = "mitre", cap_style = "square") 248 new_tiles = [tiling_utils.get_clean_polygon(inset_tile.intersection(e)) 249 for e in self.tiles.geometry] 250 result = copy.deepcopy(self) 251 result.tiles.geometry = gpd.GeoSeries(new_tiles) 252 return result
Return new TileUnit clipped by negatively buffered regularised_prototile.
Note that geopandas clip is not order preserving hence we do this one polygon at a time.
Arguments:
- d (float, optional): the inset distance. Defaults to 0.
Returns:
TileUnit: the new TileUnit with inset applied.