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 argument offset 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 and offset.
  • "crosses" colourings of cross-shaped pentominoes of between

    Spacing and coordinate reference of the tile unit are specified by the weavingspace.tileable.Tileable superclass variables weavingspace.tileable.Tileable.spacing and weavingspace.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
@dataclass
class TileUnit(weavingspace.tileable.Tileable):
 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.

TileUnit(**kwargs: str)
 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.

tiling_type: str = ''

tiling type as detailed in the class documentation preamble.

offset: float = 1

offset for 'dissection' and 'slice' tilings. Defaults to 1.

n: int = 3

number of dissections or colours in 'hex-dissection', 'hex-slice' and 'hex-colouring' tilings. Defaults to 3.

code: str = '3.3.4.3.4'

the code for 'laves' or 'archimedean' tiling types.

def inset_prototile(self, d: float = 0) -> TileUnit:
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.