weavingspace.tile_unit
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
https://en.wikipedia.org/wiki/List_of_Euclidean_uniform_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 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
code
which 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
n
andoffset
. "crosses" colourings of cross-shaped pentominoes of between
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"""`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 tg 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 tg._setup_cairo(self) 120 case x if "hex" in x and "slice" in x: 121 return tg._setup_hex_slice(self) 122 case x if "hex" in x and "dissect" in x: 123 return tg._setup_hex_dissection(self) 124 case x if "hex" in x and "col" in x: 125 return tg._setup_hex_colouring(self) 126 case x if "square" in x and "slice" in x: 127 return tg._setup_square_slice(self) 128 case x if "square" in x and "dissect" in x: 129 return tg._setup_square_dissection(self) 130 case x if "square" in x and "col" in x: 131 return tg._setup_square_colouring(self) 132 case x if "lave" in x: 133 return tg._setup_laves(self) 134 case x if "archi" in x: 135 return tg._setup_archimedean(self) 136 case x if "star1" in x: 137 return tg._setup_star_polygon_1(self) 138 case x if "star2" in x: 139 return tg._setup_star_polygon_2(self) 140 case x if "chavey" in x: 141 return tg._setup_chavey(self) 142 case x if "cross" in x: 143 return tg._setup_crosses(self) 144 case _: 145 return tg._setup_base_tiling(self) 146 147 148 def _setup_regularised_prototile( 149 self, 150 override:bool = False, 151 ) -> None: 152 """Set up a 'regularised prototile' which contains all tile elements. 153 154 The regularised prototile does not cut the tile elements. In all TileUnit 155 cases a suitable shape is the union of the elements. 156 """ 157 # For whatever reasons in the web app version the unioning operation as 158 # operated by tiling_utils.safe_union() is anything but and produces 159 # TopologyException crashes... so here is a safe_union avoidant way... 160 if self.regularised_prototile is None or override: 161 tiles = copy.deepcopy(self.tiles.geometry) 162 tiles = gpd.GeoSeries( 163 [tiling_utils.gridify(p.buffer( 164 tiling_utils.RESOLUTION * 10, join_style=2, cap_style=3)) 165 for p in tiles]) 166 self.regularised_prototile = gpd.GeoDataFrame( 167 geometry = gpd.GeoSeries( 168 [tiles.union_all().buffer( 169 -tiling_utils.RESOLUTION * 10, 170 join_style = "mitre", cap_style = "square").simplify( 171 tiling_utils.RESOLUTION * 10)]), 172 crs = self.crs) 173 174 175 def _get_legend_key_shapes( 176 self, 177 polygon:geom.Polygon, 178 counts:Iterable = [1] * 25, 179 angle:float = 0, 180 radial:bool = False, 181 ) -> list[geom.Polygon]: 182 """Return a set of shapes useable as a legend key for the supplied polygon. 183 184 In TileUnit this is a set of 'nested' polygons formed 185 by serially negative buffering the tile shapes. 186 187 Args: 188 polygon (geom.Polygon): the polygon to symbolise. 189 counts (Iterable, optional): iterable of the counts of each slice. 190 Defaults to [1] * 25. 191 angle (float, optional): rotation that may have to be applied. Not used in 192 the TileUnit case. Defaults to 0. 193 radial (bool): if True then a pie slice dissection will be applied; if 194 False then a set of 'nested' shapes will be applied. Defaults to False. 195 196 Returns: 197 list[geom.Polygon]: a list of nested polygons. 198 199 """ 200 if radial: 201 n = sum(counts) 202 slice_posns = list(np.cumsum(counts)) 203 slice_posns = [0] + [p / n for p in slice_posns] 204 return [tiling_utils.get_polygon_sector(polygon, i, j) 205 for i, j in zip(slice_posns[:-1], slice_posns[1:], strict = True)] 206 n = sum(counts) 207 bandwidths = [c / n for c in counts] 208 bandwidths = [bw if bw > 0.05 or bw == 0 else 0.05 209 for bw in bandwidths] 210 n = sum(bandwidths) 211 bandwidths = [0] + [bw / n for bw in bandwidths] 212 # sqrt exaggerates outermost annuli, which can otherwise disappear 213 bandwidths = [np.sqrt(bw) for bw in bandwidths] 214 distances = np.cumsum(bandwidths) 215 # get the negative buffer distance that will 'collapse' the polygon 216 radius = tiling_utils.get_apothem(polygon) 217 distances = distances * radius / distances[-1] 218 nested_polys = [ 219 polygon.buffer(-d, join_style = "mitre", cap_style = "square") 220 for d in distances] 221 # DON'T CONVERT TO ANNULI - it washes the final colours out in rendering 222 return [p for c, p in zip(counts, nested_polys) if c > 0] # noqa: B905 223 224 225 def inset_prototile(self, d:float = 0) -> TileUnit: 226 """Return new TileUnit clipped by negatively buffered regularised_prototile. 227 228 Note that geopandas clip is not order preserving hence we do this one 229 polygon at a time. 230 231 Args: 232 d (float, optional): the inset distance. Defaults to 0. 233 234 Returns: 235 TileUnit: the new TileUnit with inset applied. 236 237 """ 238 if d == 0: 239 return self 240 inset_tile:gpd.GeoSeries = \ 241 self.regularised_prototile.loc[0, "geometry"] \ 242 .buffer(-d, join_style = "mitre", cap_style = "square") 243 new_tiles = [tiling_utils.get_clean_polygon(inset_tile.intersection(e)) 244 for e in self.tiles.geometry] 245 result = copy.deepcopy(self) 246 result.tiles.geometry = gpd.GeoSeries(new_tiles) 247 return result 248 249 250 def _as_circles(self) -> TileUnit: 251 """Experimental implementation of returning tiles as their incircles. 252 253 Returns: 254 TileUnit: a tiling which replaces each tile with its incircle. 255 256 """ 257 result = copy.deepcopy(self) 258 result.tiles.geometry = gpd.GeoSeries([tiling_utils.get_incircle(p) 259 for p in self.tiles.geometry]) 260 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 tg._setup_cairo(self) 121 case x if "hex" in x and "slice" in x: 122 return tg._setup_hex_slice(self) 123 case x if "hex" in x and "dissect" in x: 124 return tg._setup_hex_dissection(self) 125 case x if "hex" in x and "col" in x: 126 return tg._setup_hex_colouring(self) 127 case x if "square" in x and "slice" in x: 128 return tg._setup_square_slice(self) 129 case x if "square" in x and "dissect" in x: 130 return tg._setup_square_dissection(self) 131 case x if "square" in x and "col" in x: 132 return tg._setup_square_colouring(self) 133 case x if "lave" in x: 134 return tg._setup_laves(self) 135 case x if "archi" in x: 136 return tg._setup_archimedean(self) 137 case x if "star1" in x: 138 return tg._setup_star_polygon_1(self) 139 case x if "star2" in x: 140 return tg._setup_star_polygon_2(self) 141 case x if "chavey" in x: 142 return tg._setup_chavey(self) 143 case x if "cross" in x: 144 return tg._setup_crosses(self) 145 case _: 146 return tg._setup_base_tiling(self) 147 148 149 def _setup_regularised_prototile( 150 self, 151 override:bool = False, 152 ) -> None: 153 """Set up a 'regularised prototile' which contains all tile elements. 154 155 The regularised prototile does not cut the tile elements. In all TileUnit 156 cases a suitable shape is the union of the elements. 157 """ 158 # For whatever reasons in the web app version the unioning operation as 159 # operated by tiling_utils.safe_union() is anything but and produces 160 # TopologyException crashes... so here is a safe_union avoidant way... 161 if self.regularised_prototile is None or override: 162 tiles = copy.deepcopy(self.tiles.geometry) 163 tiles = gpd.GeoSeries( 164 [tiling_utils.gridify(p.buffer( 165 tiling_utils.RESOLUTION * 10, join_style=2, cap_style=3)) 166 for p in tiles]) 167 self.regularised_prototile = gpd.GeoDataFrame( 168 geometry = gpd.GeoSeries( 169 [tiles.union_all().buffer( 170 -tiling_utils.RESOLUTION * 10, 171 join_style = "mitre", cap_style = "square").simplify( 172 tiling_utils.RESOLUTION * 10)]), 173 crs = self.crs) 174 175 176 def _get_legend_key_shapes( 177 self, 178 polygon:geom.Polygon, 179 counts:Iterable = [1] * 25, 180 angle:float = 0, 181 radial:bool = False, 182 ) -> list[geom.Polygon]: 183 """Return a set of shapes useable as a legend key for the supplied polygon. 184 185 In TileUnit this is a set of 'nested' polygons formed 186 by serially negative buffering the tile shapes. 187 188 Args: 189 polygon (geom.Polygon): the polygon to symbolise. 190 counts (Iterable, optional): iterable of the counts of each slice. 191 Defaults to [1] * 25. 192 angle (float, optional): rotation that may have to be applied. Not used in 193 the TileUnit case. Defaults to 0. 194 radial (bool): if True then a pie slice dissection will be applied; if 195 False then a set of 'nested' shapes will be applied. Defaults to False. 196 197 Returns: 198 list[geom.Polygon]: a list of nested polygons. 199 200 """ 201 if radial: 202 n = sum(counts) 203 slice_posns = list(np.cumsum(counts)) 204 slice_posns = [0] + [p / n for p in slice_posns] 205 return [tiling_utils.get_polygon_sector(polygon, i, j) 206 for i, j in zip(slice_posns[:-1], slice_posns[1:], strict = True)] 207 n = sum(counts) 208 bandwidths = [c / n for c in counts] 209 bandwidths = [bw if bw > 0.05 or bw == 0 else 0.05 210 for bw in bandwidths] 211 n = sum(bandwidths) 212 bandwidths = [0] + [bw / n for bw in bandwidths] 213 # sqrt exaggerates outermost annuli, which can otherwise disappear 214 bandwidths = [np.sqrt(bw) for bw in bandwidths] 215 distances = np.cumsum(bandwidths) 216 # get the negative buffer distance that will 'collapse' the polygon 217 radius = tiling_utils.get_apothem(polygon) 218 distances = distances * radius / distances[-1] 219 nested_polys = [ 220 polygon.buffer(-d, join_style = "mitre", cap_style = "square") 221 for d in distances] 222 # DON'T CONVERT TO ANNULI - it washes the final colours out in rendering 223 return [p for c, p in zip(counts, nested_polys) if c > 0] # noqa: B905 224 225 226 def inset_prototile(self, d:float = 0) -> TileUnit: 227 """Return new TileUnit clipped by negatively buffered regularised_prototile. 228 229 Note that geopandas clip is not order preserving hence we do this one 230 polygon at a time. 231 232 Args: 233 d (float, optional): the inset distance. Defaults to 0. 234 235 Returns: 236 TileUnit: the new TileUnit with inset applied. 237 238 """ 239 if d == 0: 240 return self 241 inset_tile:gpd.GeoSeries = \ 242 self.regularised_prototile.loc[0, "geometry"] \ 243 .buffer(-d, join_style = "mitre", cap_style = "square") 244 new_tiles = [tiling_utils.get_clean_polygon(inset_tile.intersection(e)) 245 for e in self.tiles.geometry] 246 result = copy.deepcopy(self) 247 result.tiles.geometry = gpd.GeoSeries(new_tiles) 248 return result 249 250 251 def _as_circles(self) -> TileUnit: 252 """Experimental implementation of returning tiles as their incircles. 253 254 Returns: 255 TileUnit: a tiling which replaces each tile with its incircle. 256 257 """ 258 result = copy.deepcopy(self) 259 result.tiles.geometry = gpd.GeoSeries([tiling_utils.get_incircle(p) 260 for p in self.tiles.geometry]) 261 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.
226 def inset_prototile(self, d:float = 0) -> TileUnit: 227 """Return new TileUnit clipped by negatively buffered regularised_prototile. 228 229 Note that geopandas clip is not order preserving hence we do this one 230 polygon at a time. 231 232 Args: 233 d (float, optional): the inset distance. Defaults to 0. 234 235 Returns: 236 TileUnit: the new TileUnit with inset applied. 237 238 """ 239 if d == 0: 240 return self 241 inset_tile:gpd.GeoSeries = \ 242 self.regularised_prototile.loc[0, "geometry"] \ 243 .buffer(-d, join_style = "mitre", cap_style = "square") 244 new_tiles = [tiling_utils.get_clean_polygon(inset_tile.intersection(e)) 245 for e in self.tiles.geometry] 246 result = copy.deepcopy(self) 247 result.tiles.geometry = gpd.GeoSeries(new_tiles) 248 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.
Args: d (float, optional): the inset distance. Defaults to 0.
Returns: TileUnit: the new TileUnit with inset applied.