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 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 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 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#!/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
@dataclass
class TileUnit(weavingspace.tileable.Tileable):
 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.

TileUnit(**kwargs)
 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()
tiling_type: str = None

tiling type as detailed in the class documentation preamble.

offset: int = 1

offset for 'hex-dissection' and 'hex-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'

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

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

def scale_tiles(self, sf: float = 1) -> TileUnit:
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.