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 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
@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 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.

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:
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.