weavingspace.tileable

Implements Tileable and TileShape.

Tileable should not be called directly, but is instead accessed from the weavingspace.tile_unit.TileUnit or weavingspace.weave_unit.WeaveUnit constructors.

Several methods of weavingspace.tileable.Tileable are generally useful and can be accessed through its subclasses.

  1"""Implements Tileable and TileShape.
  2
  3`Tileable` should not be called directly, but is instead accessed from the
  4`weavingspace.tile_unit.TileUnit` or `weavingspace.weave_unit.WeaveUnit`
  5constructors.
  6
  7Several methods of `weavingspace.tileable.Tileable` are generally useful and
  8can be accessed through its subclasses.
  9"""
 10
 11import copy
 12from dataclasses import dataclass
 13from enum import Enum
 14
 15import geopandas as gpd
 16import numpy as np
 17import shapely.affinity as affine
 18import shapely.geometry as geom
 19from matplotlib import pyplot as plt
 20
 21from weavingspace import tiling_utils
 22
 23
 24class TileShape(Enum):
 25  """The available base tile shapes.
 26
 27  NOTE: the TRIANGLE type does not persist, but should be converted to a
 28  DIAMOND or HEXAGON type during `Tileable` construction.
 29  """
 30
 31  RECTANGLE = "rectangle"
 32  HEXAGON = "hexagon"
 33  TRIANGLE = "triangle"
 34  DIAMOND = "diamond"
 35
 36
 37@dataclass
 38class Tileable:
 39  """Class to represent a tileable set of tile geometries."""
 40
 41  tiles:gpd.GeoDataFrame|None = None
 42  """the geometries with associated `title_id` attribute encoding their
 43  different colouring."""
 44  prototile:gpd.GeoDataFrame|None = None
 45  """the tileable polygon (rectangle, hexagon or diamond)"""
 46  spacing:float = 1000.0
 47  """the tile spacing effectively the resolution of the tiling. Defaults to
 48  1000"""
 49  base_shape:TileShape = TileShape.RECTANGLE
 50  """the tile shape. Defaults to 'RECTANGLE'"""
 51  vectors:dict[tuple[int,...],tuple[float,...]]|None = None
 52  """translation vector symmetries of the tiling"""
 53  regularised_prototile:gpd.GeoDataFrame|None = None
 54  """polygon containing the tiles of this tileable, usually a union of its
 55  tile polygons"""
 56  crs:int = 3857
 57  """coordinate reference system of the tile. Most often an ESPG code but
 58  any valid geopandas CRS specification is valid. Defaults to 3857 (i.e. Web
 59  Mercator)."""
 60  rotation:float = 0.0
 61  """cumulative rotation of the tileable."""
 62  debug:bool = False
 63  """if True prints debug messages. Defaults to False."""
 64
 65  # Tileable constructor called by subclasses - should not be used directly
 66  def __init__(self,      # noqa: D107
 67               **kwargs,  # noqa: ANN003
 68              ) -> None:
 69    for k, v in kwargs.items():
 70      if isinstance(v, str):
 71        # make any string arguments lower case
 72        self.__dict__[k] = v.lower()
 73      else:
 74        self.__dict__[k] = v
 75    # delegate making the tiles back to the subclass _setup_tiles() method
 76    # which is implemented differently by TileUnit and WeavUnit. It will return
 77    # a message if there's a problem.
 78    # There might be a try... except... way to do this more 'properly', but we
 79    # prefer to return something even if it's not what was requested - along
 80    # with an explanation / suggestion
 81    message = self._setup_tiles()
 82    if message is not None: # there was a problem
 83      print(message)
 84      self._setup_default_tileable()
 85    else:
 86      self.prototile = self.get_prototile_from_vectors()
 87      self._setup_regularised_prototile()
 88
 89
 90  def setup_vectors(
 91        self,
 92        *args,  # noqa: ANN002
 93      ) -> None:
 94    """Set up translation vectors of a Tileable.
 95
 96    Initialised from either two or three supplied tuples. Two non-parallel
 97    vectors are sufficient for a tiling to work, but usually three will be
 98    supplied for tiles with a hexagonal base tile. We also store the reverse
 99    vectors - this is for convenience when finding a 'local patch'. This method
100    is preferred during Tileable initialisation.
101
102    The vectors are stored in a dictionary indexed by their
103    coordinates, e.g.
104
105      {( 1,  0): ( 100, 0), ( 0,  1): (0,  100),
106       (-1,  0): (-100, 0), ( 0, -1): (0, -100)}
107
108    For a tileable of type `TileShape.HEXAGON`, the indexing tuples
109    have three components. See https://www.redblobgames.com/grids/hexagons/
110    """
111    vectors = list(args)
112    # extend list to include the inverse vectors too
113    for v in args:
114      vectors = [*vectors, (-v[0], -v[1])]
115    if len(vectors) == 4:
116      i = [1, 0, -1,  0]
117      j = [0, 1,  0, -1]
118      self.vectors = {
119        (i, j): v for i, j, v in zip(i, j, vectors, strict = True)}
120    else:
121      i = [ 0,  1,  1,  0, -1, -1]
122      j = [ 1,  0, -1, -1,  0,  1]
123      k = [-1, -1,  0,  1,  1,  0]
124      self.vectors = {
125        (i, j, k): v for i, j, k, v in zip(i, j, k, vectors, strict = True)}
126
127
128  def get_vectors(
129      self,
130      as_dict: bool = False,
131      ) -> list[tuple[float,...]]|dict[tuple[int,...],tuple[float,...]]:
132    """Return symmetry translation vectors as floating point pairs.
133
134    Optionally returns the vectors in a dictionary indexed by offsets in grid
135    coordinates, e.g.
136
137      {( 1,  0): ( 100, 0), ( 0,  1): (0,  100),
138       (-1,  0): (-100, 0), ( 0, -1): (0, -100)}
139
140    Returns:
141      dict[tuple[int],tuple[float]]|list[tuple[float]]: either the vectors as a
142        list of float tuples, or a dictionary of those vectors indexed by
143        integer coordinate tuples.
144
145    """
146    if as_dict:
147      return self.vectors
148    return list(self.vectors.values())
149
150
151  def get_prototile_from_vectors(self) -> gpd.GeoDataFrame:
152    r"""Contruct and returns a prototile unit based on vectors of the Tileable.
153
154    For rectangular tilings the prototile is formed by points
155    at diagonal corners defined by the halved vectors. By inspection, each edge
156    of the prototile is the resultant of adding two of the four vectors.
157
158      ----
159     |\  /|
160     | \/ |
161     | /\ |
162     |/  \|
163      ----
164
165    In the hexagonal case we form three such quadrilaterals (but don't halve the
166    vectors, because we need the extended length) and intersect them to find a
167    hexagonal shape. This guarantees that each vector will connect two opposite
168    faces of the hexagon, as desired. This seems the most elegant approach by
169    geometric construction.
170
171    The prototile is not uniquely defined. The shape returned by this method is
172    not guaranteed to be the most 'obvious' one that a human might construct!
173
174    Returns:
175      gpd.GeoDataFrame: A suitable prototile shape for the tiling wrapped in a
176        GeoDataFrame.
177
178    """
179    vecs = self.get_vectors()
180    if len(vecs) == 4:
181      v1, v2, v3, v4 = [(x / 2, y / 2) for (x, y) in vecs]
182      prototile = geom.Polygon([(v1[0] + v2[0], v1[1] + v2[1]),
183                                (v2[0] + v3[0], v2[1] + v3[1]),
184                                (v3[0] + v4[0], v3[1] + v4[1]),
185                                (v4[0] + v1[0], v4[1] + v1[1])])
186    else:
187      v1, v2, v3, v4, v5, v6 = vecs
188      q1 = geom.Polygon([v1, v2, v4, v5])
189      q2 = geom.Polygon([v2, v3, v5, v6])
190      q3 = geom.Polygon([v3, v4, v6, v1])
191      prototile = q3.intersection(q2).intersection(q1)
192    return gpd.GeoDataFrame(
193      geometry = gpd.GeoSeries([prototile]),
194      crs = self.crs)
195
196
197  def _regularise_tiles(self) -> None:
198    """Combine tiles with same tile_id into single tiles.
199
200    This is used where some tile fragments might be on opposite sides of the
201    initial Tileable, but would end up touching after tiling. Most likely to be
202    applicable to WeaveUnit Tileables.
203
204    Also adjusts the `Tileable.regularised_prototile` attribute accordingly.
205    """
206    self.regularised_prototile = copy.deepcopy(self.prototile)
207    # This preserves order while finding uniques, unlike list(set()).
208    # Reordering ids might cause confusion when colour palettes
209    # are not assigned explicitly to each id, but in the order
210    # encountered in the tile_id Series of the GeoDataFrame.
211    tiles, tile_ids = [], []
212    ids = list(self.tiles.tile_id.unique())
213    for ID in ids:
214      fragment_set = list(
215        self.tiles[self.tiles.tile_id == ID].geometry)
216      merge_result = self._merge_fragments(fragment_set)
217      tiles.extend(merge_result)
218      tile_ids.extend([ID] * len(merge_result))
219
220    self.tiles = gpd.GeoDataFrame(
221      data = {"tile_id": tile_ids},
222      crs = self.crs,
223      geometry = gpd.GeoSeries([tiling_utils.get_clean_polygon(t)
224                                for t in tiles]))
225
226    self.regularised_prototile = \
227      self.regularised_prototile.explode(ignore_index = True)
228    if self.regularised_prototile.shape[0] > 1:
229      self.regularised_prototile.geometry = tiling_utils.get_largest_polygon(
230        self.regularised_prototile.geometry)
231
232
233  def _merge_fragments(
234      self,
235      fragments:list[geom.Polygon],
236    ) -> list[geom.Polygon]:
237    """Merge a set of polygons if they touch under the translation vectors.
238
239    Called by `regularise_tiles()` to combine tiles in a tile unit that
240    may be fragmented as supplied but will combine after tiling into single
241    tiles. This step makes for more efficient implementation of the
242    tiling of map regions, and also adds to the woven look in particular
243    where it means that 'threads' go beyond the edges of the tile shapes.
244
245    Args:
246      fragments (list[geom.Polygon]): A set of polygons to merge.
247
248    Returns:
249      list[geom.Polygon]: A minimal list of merged polygons.
250
251    """
252    if len(fragments) == 1:
253      return [f for f in fragments if not f.is_empty]
254    fragments = [f for f in fragments if not f.is_empty]
255    prototile = self.prototile.loc[0, "geometry"]
256    reg_prototile = copy.deepcopy(
257      self.regularised_prototile.loc[0, "geometry"])
258    changes_made = True
259    while changes_made:
260      changes_made = False
261      for v in self.vectors.values():
262        # empty list to collect the new fragments
263        # assembled in this iteration
264        next_frags = []
265        t_frags = [affine.translate(f, v[0], v[1]) for f in fragments]
266        # build a set of any near matching pairs of
267        # fragments and their translated copies
268        matches = set()
269        for i, f1 in enumerate(fragments):
270          for j, f2, in enumerate(t_frags):
271            if i < j and tiling_utils.touch_along_an_edge(f1, f2):
272              matches.add((i, j))
273        # determine which of these when unioned has the larger area in common
274        # with the prototile
275        done_frags = set()
276        for i, j in matches:
277          f1, f2 = fragments[i], t_frags[j]
278          u1 = f1.buffer(tiling_utils.RESOLUTION * 2,
279                         join_style = "mitre", cap_style = "square").union(
280            f2.buffer(tiling_utils.RESOLUTION * 2,
281                      join_style = "mitre", cap_style = "square"))
282          u2 = affine.translate(u1, -v[0], -v[1])
283          if prototile.intersection(u1).area > prototile.intersection(u2).area:
284            u1 = u1.buffer(-tiling_utils.RESOLUTION * 2,
285                           join_style = "mitre", cap_style = "square")
286            u2 = u2.buffer(-tiling_utils.RESOLUTION * 2,
287                           join_style = "mitre", cap_style = "square")
288            next_frags.append(u1)
289            reg_prototile = reg_prototile.union(u1).difference(u2)
290          else:
291            u1 = u1.buffer(-tiling_utils.RESOLUTION * 2,
292                           join_style = "mitre", cap_style = "square")
293            u2 = u2.buffer(-tiling_utils.RESOLUTION * 2,
294                           join_style = "mitre", cap_style = "square")
295            next_frags.append(u2)
296            reg_prototile = reg_prototile.union(u2).difference(u1)
297          changes_made = True
298          done_frags.add(i)
299          done_frags.add(j)
300        fragments = [f for i, f in enumerate(fragments)
301                     if i not in done_frags] + next_frags
302    self.regularised_prototile.loc[0, "geometry"] = reg_prototile
303    return [f for f in fragments if not f.is_empty] # don't return any duds
304
305
306  def get_local_patch(
307      self,
308      r:int = 1,
309      include_0:bool = False,
310    ) -> gpd.GeoDataFrame:
311    """Return a GeoDataFrame with translated copies of the Tileable.
312
313    The geodataframe takes the same form as the `Tileable.tile` attribute.
314
315    Args:
316      r (int, optional): the number of 'layers' out from the unit to
317        which the translate copies will extendt. Defaults to `1`.
318      include_0 (bool, optional): If True includes the Tileable itself at
319        (0, 0). Defaults to `False`.
320
321    Returns:
322      gpd.GeoDataFrame: A GeoDataframe of the tiles extended by a number
323        of 'layers'.
324
325    """
326    # a dictionary of all the vectors we need, starting with (0, 0)
327    three_vecs = len(next(iter(self.vectors.keys()))) == 3
328    vecs = {(0, 0, 0): (0, 0)} if three_vecs else {(0, 0): (0, 0)}
329    steps = r if three_vecs else r * 2
330    # a dictionary of the last 'layer' of added vectors
331    last_vecs = copy.deepcopy(vecs)
332    # get the translation vectors in a dictionary indexed by coordinates
333    # we keep track of the sum of vectors using the (integer) coordinates
334    # to avoid duplication of moves due to floating point inaccuracies
335    vectors = self.get_vectors(as_dict = True)
336    for i in range(steps):
337      new_vecs = {}
338      for k1, v1 in last_vecs.items():
339        for k2, v2 in vectors.items():
340          # add the coordinates to make a new key...
341          new_key = tuple([k1[i] + k2[i] for i in range(len(k1))])
342          # if we haven't reached here before store the actual vector
343          if new_key not in vecs:
344            new_vecs[new_key] = (v1[0] + v2[0], v1[1] + v2[1])
345      # extend the vectors and set the last layer to the set just added
346      vecs = vecs | new_vecs
347      last_vecs = new_vecs
348    if not include_0:  # throw away the identity vector
349      vecs.pop((0, 0, 0) if three_vecs else (0, 0))
350    ids, tiles = [], []
351    # we need to add the translated prototiles in order of their distance from
352    # tile 0, esp. in the square case, i.e. something like this:
353    #
354    #      5 4 3 4 5
355    #      4 2 1 2 4
356    #      3 1 0 1 3
357    #      4 2 1 2 4
358    #      5 4 3 4 5
359    #
360    # this is important for topology detection, where filtering back to the
361    # local patch of radius 1 is simplified if prototiles have been added in
362    # this order. We use the vector index tuples not the euclidean distances
363    # because this is more resistant to odd effects for non-convex tiles
364    extent = self.get_prototile_from_vectors().loc[0, "geometry"]
365    extent = affine.scale(extent,
366                          2 * r + tiling_utils.RESOLUTION,
367                          2 * r + tiling_utils.RESOLUTION)
368    vector_lengths = {index: np.sqrt(sum([_ ** 2 for _ in index]))
369                      for index in vecs}
370    ordered_vector_keys = [k for k, v in sorted(vector_lengths.items(),
371                                                key = lambda item: item[1])]
372    for k in ordered_vector_keys:
373      v = vecs[k]
374      if geom.Point(v[0], v[1]).within(extent):
375        ids.extend(self.tiles.tile_id)
376        tiles.extend(
377          self.tiles.geometry.apply(affine.translate, xoff = v[0], yoff = v[1]))
378    return gpd.GeoDataFrame(
379      data = {"tile_id": ids}, crs=self.crs,
380      geometry = gpd.GeoSeries(tiles))
381
382
383  # applicable to both TileUnits and WeaveUnits
384  def inset_tiles(
385      self,
386      inset:float = 0) -> "Tileable":
387    """Return a new Tileable with an inset applied around the tiles.
388
389    Works by applying a negative buffer of specfied size to all tiles.
390    Tiles that collapse to zero area are removed and the tile_id
391    attribute updated accordingly.
392
393    NOTE: this method is likely to not preserve the relative area of tiles.
394
395    Args:
396      inset (float, optional): The distance to inset. Defaults to `0`.
397
398    Returns:
399      "Tileable": the new inset Tileable.
400
401    """
402    inset_tiles, inset_ids = [], []
403    for p, ID in zip(self.tiles.geometry, self.tiles.tile_id, strict = True):
404      b = p.buffer(-inset, join_style = "mitre", cap_style = "square")
405      if not b.area <= 0:
406        inset_tiles.append(b)
407        inset_ids.append(ID)
408    result = copy.deepcopy(self)
409    result.tiles = gpd.GeoDataFrame(
410      data={"tile_id": inset_ids},
411      crs=self.crs,
412      geometry=gpd.GeoSeries(inset_tiles),
413    )
414    return result
415
416
417  def scale_tiles(
418      self,
419      sf:float = 1,
420      individually:bool = False,
421    ) -> "Tileable":
422    """Scales the tiles by the specified factor, centred on (0, 0).
423
424    Args:
425      sf (float, optional): scale factor to apply. Defaults to 1.
426      individually (bool, optional): if True scaling is applied to each tiling
427        element centred on its centre, rather than with respect to the Tileable.
428        Defaults to False.
429
430    Returns:
431      TileUnit: the scaled TileUnit.
432
433    """
434    if individually:
435      self.tiles.geometry = gpd.GeoSeries(
436        [affine.scale(g, sf, sf) for g in self.tiles.geometry])
437    else:
438      self.tiles.geometry = self.tiles.geometry.scale(sf, sf, origin = (0, 0))
439    return self
440
441
442  def transform_scale(
443      self,
444      xscale:float = 1.0,
445      yscale:float = 1.0,
446      independent_of_tiling:bool = False,
447    ) -> "Tileable":
448    """Transform tileable by scaling.
449
450    Args:
451      xscale (float, optional): x scale factor. Defaults to 1.0.
452      yscale (float, optional): y scale factor. Defaults to 1.0.
453      independent_of_tiling (bool, optional): if True Tileable is scaled while
454        leaving the translation vectors untouched, so that it can change size
455        independent from its spacing when tiled. Defaults to False.
456
457    Returns:
458      Tileable: the transformed Tileable.
459
460    """
461    result = copy.deepcopy(self)
462    result.tiles.geometry = self.tiles.geometry.scale(
463      xscale, yscale, origin=(0, 0))
464    if not independent_of_tiling:
465      result.prototile.geometry = self.prototile.geometry.scale(
466        xscale, yscale, origin=(0, 0))
467      result.regularised_prototile.geometry = \
468        self.regularised_prototile.geometry.scale(xscale, yscale, origin=(0, 0))
469      result._set_vectors_from_prototile()
470    return result
471
472
473  def transform_rotate(
474      self,
475      angle:float = 0.0,
476      independent_of_tiling:bool = False,
477    ) -> "Tileable":
478    """Transform tiling by rotation.
479
480    Args:
481      angle (float, optional): angle to rotate by. Defaults to 0.0.
482      independent_of_tiling (bool, optional): if True Tileable is rotated while
483        leaving the translation vectors untouched, so that it can change
484        orientation independent from its position when tiled. Defaults to False.
485
486    Returns:
487      Tileable: the transformed Tileable.
488
489    """
490    result = copy.deepcopy(self)
491    result.tiles.geometry = self.tiles.geometry.rotate(angle, origin=(0, 0))
492    if not independent_of_tiling:
493      result.prototile.geometry = \
494        self.prototile.geometry.rotate(angle, origin=(0, 0))
495      result.regularised_prototile.geometry = \
496        self.regularised_prototile.geometry.rotate(angle, origin=(0, 0))
497      result._set_vectors_from_prototile()
498      result.rotation = self.rotation + angle
499    return result
500
501
502  def transform_skew(
503      self,
504      xa:float = 0.0,
505      ya:float = 0.0,
506      independent_of_tiling:bool = False,
507      rescale = True
508    ) -> "Tileable":
509    """Transform tiling by skewing.
510
511    Args:
512      xa (float, optional): x direction skew. Defaults to 0.0.
513      ya (float, optional): y direction skew. Defaults to 0.0.
514      independent_of_tiling (bool, optional): if True Tileable is skewed while
515        leaving the translation vectors untouched, so that it can change shape
516        independent from its situation when tiled. Defaults to False.
517      rescale (bool, optional): if True rescales the result so that overall
518        size and extent is more or less unaffected by the skew, otherwise the
519        simultaneous application of both x and y shears may dramatically alter
520        the size of tiling. Defaults to True.
521
522    Returns:
523      Tileable: the transformed Tileable.
524
525    """
526    result = copy.deepcopy(self)
527    area_0 = result.prototile.geometry[0].area
528    result.tiles.geometry = (self.tiles.geometry
529                             .skew(xa, ya, origin=(0, 0)))
530    if not independent_of_tiling:
531      result.prototile.geometry = (self.prototile.geometry
532                                   .skew(xa, ya, origin=(0, 0)))
533      result.regularised_prototile.geometry = (
534        self.regularised_prototile.geometry
535            .skew(xa, ya, origin=(0, 0)))
536      result._set_vectors_from_prototile()
537    if rescale and xa != 0 and ya != 0:
538      area_1 = result.prototile.geometry[0].area
539      sf = np.sqrt(area_0 / area_1)
540      return result.transform_scale(sf, sf, independent_of_tiling)
541    else:
542      return result
543
544
545  def _set_vectors_from_prototile(self) -> None:
546    """Set translation vectors by derivation from a prototile shape.
547
548    Intended to be used internally, after a transform_scale, _skew, or _rotate.
549
550    These are 'face to face' vectors of the prototile, so that a hexagonal tile
551    will have 3 vectors, not the minimal parallelogram pair. Also sets the
552    inverse vectors. See `Tileable.setup_vectors()` for details.
553    """
554    t = self.prototile.loc[0, "geometry"]
555    points = list(t.exterior.coords)[:-1] # each point once only no wrap
556    n_pts = len(points)
557    vec_dict = {}
558    if n_pts == 4:
559      vecs = [(q[0] - p[0], q[1] - p[1])
560          for p, q in zip(points, points[1:] + points[:1], strict = True)]
561      i = [1, 0, -1,  0]
562      j = [0, 1,  0, -1]
563      vec_dict = {(i, j): v for i, j, v in zip(i, j, vecs, strict = True)}
564    elif n_pts == 6:
565      vecs = [(q[0] - p[0], q[1] - p[1])
566          for p, q in zip(points, points[2:] + points[:2], strict = True)]
567      # hex grid coordinates associated with each of the vectors
568      i = [ 0,  1,  1,  0, -1, -1]
569      j = [ 1,  0, -1, -1,  0,  1]
570      k = [-1, -1,  0,  1,  1,  0]
571      vec_dict = {
572        (i, j, k): v for i, j, k, v in zip(i, j, k, vecs, strict = True)}
573    self.vectors = vec_dict
574
575
576  def _get_mean_translation(self):
577    return np.mean(
578      [np.sqrt(dx ** 2 + dy ** 2) for (dx, dy) in self.get_vectors()])
579
580
581  def plot(
582      self,
583      ax:plt.Axes = None,
584      show_prototile:bool = True,
585      show_reg_prototile:bool = True,
586      show_ids:str|bool = "tile_id",
587      show_vectors:bool = False,
588      r:int = 0,
589      prototile_edgecolour:str = "k",
590      reg_prototile_edgecolour:str = "r",
591      vector_edgecolour:str = "k",
592      alpha:float = 1.0,
593      r_alpha:float = 0.5,
594      cmap:list[str]|str|None = None,
595      figsize:tuple[float] = (8, 8),
596      **kwargs,                        # noqa: ANN003
597    ) -> plt.Axes:
598    """Plot Tileable on the supplied axis.
599
600    **kwargs are passed on to matplotlib.plot()
601
602    Args:
603      ax (_type_, optional): matplotlib axis to draw to. Defaults to None.
604      show_prototile (bool, optional): if `True` show the tile outline.
605        Defaults to `True`.
606      show_reg_prototile (bool, optional): if `True` show the regularised tile
607        outline. Defaults to `True`.
608      show_ids (str, optional): if `tile_id` show the tile_ids. If
609        `id` show index number. If None or `''` don't label tiles.
610        Defaults to `tile_id`.
611      show_vectors (bool, optional): if `True` show the translation
612        vectors (not the minimal pair, but those used by
613        `get_local_patch()`). Defaults to `False`.
614      r (int, optional): passed to `get_local_patch()` to show context if
615        greater than 0. Defaults to `0`.
616      r_alpha (float, optional): alpha setting for units other than the
617        central one. Defaults to 0.5.
618      prototile_edgecolour (str, optional): outline colour for the tile.
619        Defaults to `"k"`.
620      reg_prototile_edgecolour (str, optional): outline colour for the
621        regularised. Defaults to `"r"`.
622      vector_edgecolour (str, optional): colour for the translation vectors.
623        Defaults to `"k"`.
624      cmap (list[str], optional): colour map to apply to the central
625        tiles. Defaults to `None`.
626      figsize (tuple[float], optional): size of the figure.
627        Defaults to `(8, 8)`.
628
629    Returns:
630      pyplot.axes: to which calling context may add things.
631
632    """
633    w = self.prototile.loc[0, "geometry"].bounds[2] - \
634      self.prototile.loc[0, "geometry"].bounds[0]
635    n_cols = len(set(self.tiles.tile_id))
636    if n_cols > 12:
637      cm = "Spectral"
638    elif n_cols > 8:
639      cm = "Paired"
640    else:
641      cm = "Dark2"
642    if ax is None:
643      ax = self.tiles.plot(
644        column="tile_id", cmap=cm, figsize=figsize, alpha = alpha, **kwargs)
645    else:
646      self.tiles.plot(
647        ax=ax, column="tile_id", cmap=cm, figsize=figsize, **kwargs)
648    if show_ids not in [None, ""]:
649      do_label = True
650      if show_ids in ["tile_id", True]:
651        labels = self.tiles.tile_id
652      elif show_ids == "id":
653        labels = [str(i) for i in range(self.tiles.shape[0])]
654      else:
655        do_label = False
656      if do_label:
657        for ID, tile in zip(labels, self.tiles.geometry, strict = True):
658          ax.annotate(ID, (tile.centroid.x, tile.centroid.y),
659            ha = "center", va = "center", bbox = {"lw": 0, "fc": "#ffffff40"})
660    if r > 0:
661      self.get_local_patch(r=r).plot(
662        ax = ax, column = "tile_id", alpha = r_alpha, cmap = cm, **kwargs)
663    if show_prototile:
664      self.prototile.plot(ax = ax, ec = prototile_edgecolour, lw = 0.5,
665                          fc = "#00000000", **kwargs)
666    if show_vectors:  # note that arrows in mpl are dimensioned in plotspace
667      vecs = self.get_vectors()
668      for v in vecs[: len(vecs) // 2]:
669        ax.arrow(0, 0, v[0], v[1], color = vector_edgecolour, width = w * 0.002,
670          head_width = w * 0.05, length_includes_head = True, zorder = 3)
671    if show_reg_prototile:
672      self.regularised_prototile.plot(
673        ax = ax, ec = reg_prototile_edgecolour, fc = "#00000000",
674        lw = 1.5, zorder = 2, **kwargs)
675    return ax
676
677
678  def _get_legend_tiles(self) -> gpd.GeoDataFrame:
679    """Return the tiles augmented by a rotation column.
680
681    This base implementation may be overridden by specific tile unit types.
682    In particular see `weavingspace.weave_unit.WeaveUnit._get_legend_tiles()`.
683
684    Returns:
685      gpd.GeoDataFrame: the tiles GeoDataFrame with a rotation column added.
686
687    """
688    tiles = copy.deepcopy(self.tiles)
689    tiles["rotation"] = 0
690    return tiles
691
692
693  def _setup_default_tileable(self) -> None:
694    """Set up a default Tileable for when the TileUnit generators fail."""
695    # if we've somehow got to here without a base_shape, make it rectangular
696    if self.base_shape is None:
697      self.base_shape = TileShape.RECTANGLE
698    if self.spacing is None:
699      self.spacing = 1000
700    match self.base_shape:
701      case TileShape.HEXAGON:
702        ids = ["a"]
703        tiles = [tiling_utils.get_regular_polygon(self.spacing, 6)]
704        self.setup_vectors(*self._get_hex_vectors())
705      case TileShape.DIAMOND:
706        ids = ["a"]
707        tiles = [( self.spacing/2, 0), (0,  self.spacing * np.sqrt(3)/2),
708                 (-self.spacing/2, 0), (0, -self.spacing * np.sqrt(3)/2)]
709        self.setup_vectors(*self._get_diamond_vectors())
710      case TileShape.TRIANGLE:
711        ids = ["a", "b"]
712        t1 = tiling_utils.get_regular_polygon(self.spacing, 3)
713        t1 = affine.translate(t1, 0, -t1.bounds[1])
714        t2 = affine.rotate(t1, 180, origin = (0, 0))
715        tiles = [t1, t2]
716        self.setup_vectors(*self._get_diamond_vectors())
717      case _:
718        ids = ["a"]
719        tiles = [tiling_utils.get_regular_polygon(self.spacing, 4)]
720        self.setup_vectors(*self._get_square_vectors())
721    self.tiles = gpd.GeoDataFrame(data = {"tile_id": ids},
722                                  geometry = gpd.GeoSeries(tiles),
723                                  crs = 3857)
724    self.crs = 3857
725    self.prototile = self.get_prototile_from_vectors()
726    self.regularised_prototile = copy.deepcopy(self.prototile)
727
728
729  def _get_hex_vectors(self) -> list[tuple[float,float]]:
730    """Return three vectors for a hexagonal tiling.
731
732    Returns:
733        list[tuple[float]]: Translation vectors of a hexagonal tiling.
734
735    """
736    return [(v[0] * self.spacing, v[1] * self.spacing)
737            for v in [(0, 1), (np.sqrt(3)/2, 1/2), (np.sqrt(3)/2, -1/2)]]
738
739
740  def _get_square_vectors(self) -> list[tuple[float,float]]:
741    """Return two vectors for a square tiling.
742
743    Returns:
744        list[tuple[float]]: Translation vectors of a square tiling.
745
746    """
747    return [(v[0] * self.spacing, v[1] * self.spacing)
748            for v in [(1, 0), (0, 1)]]
749
750
751  def _get_diamond_vectors(self) -> list[tuple[float,float]]:
752    """Return two vectors for a diamond or triangular tiling.
753
754    Returns:
755        list[tuple[float]]: Translation vectors of a square tiling.
756
757    """
758    return [(v[0] * self.spacing, v[1] * self.spacing)
759            for v in [(1/np.sqrt(3), 1), (1/np.sqrt(3), -1)]]
class TileShape(enum.Enum):
25class TileShape(Enum):
26  """The available base tile shapes.
27
28  NOTE: the TRIANGLE type does not persist, but should be converted to a
29  DIAMOND or HEXAGON type during `Tileable` construction.
30  """
31
32  RECTANGLE = "rectangle"
33  HEXAGON = "hexagon"
34  TRIANGLE = "triangle"
35  DIAMOND = "diamond"

The available base tile shapes.

NOTE: the TRIANGLE type does not persist, but should be converted to a DIAMOND or HEXAGON type during Tileable construction.

RECTANGLE = <TileShape.RECTANGLE: 'rectangle'>
HEXAGON = <TileShape.HEXAGON: 'hexagon'>
TRIANGLE = <TileShape.TRIANGLE: 'triangle'>
DIAMOND = <TileShape.DIAMOND: 'diamond'>
@dataclass
class Tileable:
 38@dataclass
 39class Tileable:
 40  """Class to represent a tileable set of tile geometries."""
 41
 42  tiles:gpd.GeoDataFrame|None = None
 43  """the geometries with associated `title_id` attribute encoding their
 44  different colouring."""
 45  prototile:gpd.GeoDataFrame|None = None
 46  """the tileable polygon (rectangle, hexagon or diamond)"""
 47  spacing:float = 1000.0
 48  """the tile spacing effectively the resolution of the tiling. Defaults to
 49  1000"""
 50  base_shape:TileShape = TileShape.RECTANGLE
 51  """the tile shape. Defaults to 'RECTANGLE'"""
 52  vectors:dict[tuple[int,...],tuple[float,...]]|None = None
 53  """translation vector symmetries of the tiling"""
 54  regularised_prototile:gpd.GeoDataFrame|None = None
 55  """polygon containing the tiles of this tileable, usually a union of its
 56  tile polygons"""
 57  crs:int = 3857
 58  """coordinate reference system of the tile. Most often an ESPG code but
 59  any valid geopandas CRS specification is valid. Defaults to 3857 (i.e. Web
 60  Mercator)."""
 61  rotation:float = 0.0
 62  """cumulative rotation of the tileable."""
 63  debug:bool = False
 64  """if True prints debug messages. Defaults to False."""
 65
 66  # Tileable constructor called by subclasses - should not be used directly
 67  def __init__(self,      # noqa: D107
 68               **kwargs,  # noqa: ANN003
 69              ) -> None:
 70    for k, v in kwargs.items():
 71      if isinstance(v, str):
 72        # make any string arguments lower case
 73        self.__dict__[k] = v.lower()
 74      else:
 75        self.__dict__[k] = v
 76    # delegate making the tiles back to the subclass _setup_tiles() method
 77    # which is implemented differently by TileUnit and WeavUnit. It will return
 78    # a message if there's a problem.
 79    # There might be a try... except... way to do this more 'properly', but we
 80    # prefer to return something even if it's not what was requested - along
 81    # with an explanation / suggestion
 82    message = self._setup_tiles()
 83    if message is not None: # there was a problem
 84      print(message)
 85      self._setup_default_tileable()
 86    else:
 87      self.prototile = self.get_prototile_from_vectors()
 88      self._setup_regularised_prototile()
 89
 90
 91  def setup_vectors(
 92        self,
 93        *args,  # noqa: ANN002
 94      ) -> None:
 95    """Set up translation vectors of a Tileable.
 96
 97    Initialised from either two or three supplied tuples. Two non-parallel
 98    vectors are sufficient for a tiling to work, but usually three will be
 99    supplied for tiles with a hexagonal base tile. We also store the reverse
100    vectors - this is for convenience when finding a 'local patch'. This method
101    is preferred during Tileable initialisation.
102
103    The vectors are stored in a dictionary indexed by their
104    coordinates, e.g.
105
106      {( 1,  0): ( 100, 0), ( 0,  1): (0,  100),
107       (-1,  0): (-100, 0), ( 0, -1): (0, -100)}
108
109    For a tileable of type `TileShape.HEXAGON`, the indexing tuples
110    have three components. See https://www.redblobgames.com/grids/hexagons/
111    """
112    vectors = list(args)
113    # extend list to include the inverse vectors too
114    for v in args:
115      vectors = [*vectors, (-v[0], -v[1])]
116    if len(vectors) == 4:
117      i = [1, 0, -1,  0]
118      j = [0, 1,  0, -1]
119      self.vectors = {
120        (i, j): v for i, j, v in zip(i, j, vectors, strict = True)}
121    else:
122      i = [ 0,  1,  1,  0, -1, -1]
123      j = [ 1,  0, -1, -1,  0,  1]
124      k = [-1, -1,  0,  1,  1,  0]
125      self.vectors = {
126        (i, j, k): v for i, j, k, v in zip(i, j, k, vectors, strict = True)}
127
128
129  def get_vectors(
130      self,
131      as_dict: bool = False,
132      ) -> list[tuple[float,...]]|dict[tuple[int,...],tuple[float,...]]:
133    """Return symmetry translation vectors as floating point pairs.
134
135    Optionally returns the vectors in a dictionary indexed by offsets in grid
136    coordinates, e.g.
137
138      {( 1,  0): ( 100, 0), ( 0,  1): (0,  100),
139       (-1,  0): (-100, 0), ( 0, -1): (0, -100)}
140
141    Returns:
142      dict[tuple[int],tuple[float]]|list[tuple[float]]: either the vectors as a
143        list of float tuples, or a dictionary of those vectors indexed by
144        integer coordinate tuples.
145
146    """
147    if as_dict:
148      return self.vectors
149    return list(self.vectors.values())
150
151
152  def get_prototile_from_vectors(self) -> gpd.GeoDataFrame:
153    r"""Contruct and returns a prototile unit based on vectors of the Tileable.
154
155    For rectangular tilings the prototile is formed by points
156    at diagonal corners defined by the halved vectors. By inspection, each edge
157    of the prototile is the resultant of adding two of the four vectors.
158
159      ----
160     |\  /|
161     | \/ |
162     | /\ |
163     |/  \|
164      ----
165
166    In the hexagonal case we form three such quadrilaterals (but don't halve the
167    vectors, because we need the extended length) and intersect them to find a
168    hexagonal shape. This guarantees that each vector will connect two opposite
169    faces of the hexagon, as desired. This seems the most elegant approach by
170    geometric construction.
171
172    The prototile is not uniquely defined. The shape returned by this method is
173    not guaranteed to be the most 'obvious' one that a human might construct!
174
175    Returns:
176      gpd.GeoDataFrame: A suitable prototile shape for the tiling wrapped in a
177        GeoDataFrame.
178
179    """
180    vecs = self.get_vectors()
181    if len(vecs) == 4:
182      v1, v2, v3, v4 = [(x / 2, y / 2) for (x, y) in vecs]
183      prototile = geom.Polygon([(v1[0] + v2[0], v1[1] + v2[1]),
184                                (v2[0] + v3[0], v2[1] + v3[1]),
185                                (v3[0] + v4[0], v3[1] + v4[1]),
186                                (v4[0] + v1[0], v4[1] + v1[1])])
187    else:
188      v1, v2, v3, v4, v5, v6 = vecs
189      q1 = geom.Polygon([v1, v2, v4, v5])
190      q2 = geom.Polygon([v2, v3, v5, v6])
191      q3 = geom.Polygon([v3, v4, v6, v1])
192      prototile = q3.intersection(q2).intersection(q1)
193    return gpd.GeoDataFrame(
194      geometry = gpd.GeoSeries([prototile]),
195      crs = self.crs)
196
197
198  def _regularise_tiles(self) -> None:
199    """Combine tiles with same tile_id into single tiles.
200
201    This is used where some tile fragments might be on opposite sides of the
202    initial Tileable, but would end up touching after tiling. Most likely to be
203    applicable to WeaveUnit Tileables.
204
205    Also adjusts the `Tileable.regularised_prototile` attribute accordingly.
206    """
207    self.regularised_prototile = copy.deepcopy(self.prototile)
208    # This preserves order while finding uniques, unlike list(set()).
209    # Reordering ids might cause confusion when colour palettes
210    # are not assigned explicitly to each id, but in the order
211    # encountered in the tile_id Series of the GeoDataFrame.
212    tiles, tile_ids = [], []
213    ids = list(self.tiles.tile_id.unique())
214    for ID in ids:
215      fragment_set = list(
216        self.tiles[self.tiles.tile_id == ID].geometry)
217      merge_result = self._merge_fragments(fragment_set)
218      tiles.extend(merge_result)
219      tile_ids.extend([ID] * len(merge_result))
220
221    self.tiles = gpd.GeoDataFrame(
222      data = {"tile_id": tile_ids},
223      crs = self.crs,
224      geometry = gpd.GeoSeries([tiling_utils.get_clean_polygon(t)
225                                for t in tiles]))
226
227    self.regularised_prototile = \
228      self.regularised_prototile.explode(ignore_index = True)
229    if self.regularised_prototile.shape[0] > 1:
230      self.regularised_prototile.geometry = tiling_utils.get_largest_polygon(
231        self.regularised_prototile.geometry)
232
233
234  def _merge_fragments(
235      self,
236      fragments:list[geom.Polygon],
237    ) -> list[geom.Polygon]:
238    """Merge a set of polygons if they touch under the translation vectors.
239
240    Called by `regularise_tiles()` to combine tiles in a tile unit that
241    may be fragmented as supplied but will combine after tiling into single
242    tiles. This step makes for more efficient implementation of the
243    tiling of map regions, and also adds to the woven look in particular
244    where it means that 'threads' go beyond the edges of the tile shapes.
245
246    Args:
247      fragments (list[geom.Polygon]): A set of polygons to merge.
248
249    Returns:
250      list[geom.Polygon]: A minimal list of merged polygons.
251
252    """
253    if len(fragments) == 1:
254      return [f for f in fragments if not f.is_empty]
255    fragments = [f for f in fragments if not f.is_empty]
256    prototile = self.prototile.loc[0, "geometry"]
257    reg_prototile = copy.deepcopy(
258      self.regularised_prototile.loc[0, "geometry"])
259    changes_made = True
260    while changes_made:
261      changes_made = False
262      for v in self.vectors.values():
263        # empty list to collect the new fragments
264        # assembled in this iteration
265        next_frags = []
266        t_frags = [affine.translate(f, v[0], v[1]) for f in fragments]
267        # build a set of any near matching pairs of
268        # fragments and their translated copies
269        matches = set()
270        for i, f1 in enumerate(fragments):
271          for j, f2, in enumerate(t_frags):
272            if i < j and tiling_utils.touch_along_an_edge(f1, f2):
273              matches.add((i, j))
274        # determine which of these when unioned has the larger area in common
275        # with the prototile
276        done_frags = set()
277        for i, j in matches:
278          f1, f2 = fragments[i], t_frags[j]
279          u1 = f1.buffer(tiling_utils.RESOLUTION * 2,
280                         join_style = "mitre", cap_style = "square").union(
281            f2.buffer(tiling_utils.RESOLUTION * 2,
282                      join_style = "mitre", cap_style = "square"))
283          u2 = affine.translate(u1, -v[0], -v[1])
284          if prototile.intersection(u1).area > prototile.intersection(u2).area:
285            u1 = u1.buffer(-tiling_utils.RESOLUTION * 2,
286                           join_style = "mitre", cap_style = "square")
287            u2 = u2.buffer(-tiling_utils.RESOLUTION * 2,
288                           join_style = "mitre", cap_style = "square")
289            next_frags.append(u1)
290            reg_prototile = reg_prototile.union(u1).difference(u2)
291          else:
292            u1 = u1.buffer(-tiling_utils.RESOLUTION * 2,
293                           join_style = "mitre", cap_style = "square")
294            u2 = u2.buffer(-tiling_utils.RESOLUTION * 2,
295                           join_style = "mitre", cap_style = "square")
296            next_frags.append(u2)
297            reg_prototile = reg_prototile.union(u2).difference(u1)
298          changes_made = True
299          done_frags.add(i)
300          done_frags.add(j)
301        fragments = [f for i, f in enumerate(fragments)
302                     if i not in done_frags] + next_frags
303    self.regularised_prototile.loc[0, "geometry"] = reg_prototile
304    return [f for f in fragments if not f.is_empty] # don't return any duds
305
306
307  def get_local_patch(
308      self,
309      r:int = 1,
310      include_0:bool = False,
311    ) -> gpd.GeoDataFrame:
312    """Return a GeoDataFrame with translated copies of the Tileable.
313
314    The geodataframe takes the same form as the `Tileable.tile` attribute.
315
316    Args:
317      r (int, optional): the number of 'layers' out from the unit to
318        which the translate copies will extendt. Defaults to `1`.
319      include_0 (bool, optional): If True includes the Tileable itself at
320        (0, 0). Defaults to `False`.
321
322    Returns:
323      gpd.GeoDataFrame: A GeoDataframe of the tiles extended by a number
324        of 'layers'.
325
326    """
327    # a dictionary of all the vectors we need, starting with (0, 0)
328    three_vecs = len(next(iter(self.vectors.keys()))) == 3
329    vecs = {(0, 0, 0): (0, 0)} if three_vecs else {(0, 0): (0, 0)}
330    steps = r if three_vecs else r * 2
331    # a dictionary of the last 'layer' of added vectors
332    last_vecs = copy.deepcopy(vecs)
333    # get the translation vectors in a dictionary indexed by coordinates
334    # we keep track of the sum of vectors using the (integer) coordinates
335    # to avoid duplication of moves due to floating point inaccuracies
336    vectors = self.get_vectors(as_dict = True)
337    for i in range(steps):
338      new_vecs = {}
339      for k1, v1 in last_vecs.items():
340        for k2, v2 in vectors.items():
341          # add the coordinates to make a new key...
342          new_key = tuple([k1[i] + k2[i] for i in range(len(k1))])
343          # if we haven't reached here before store the actual vector
344          if new_key not in vecs:
345            new_vecs[new_key] = (v1[0] + v2[0], v1[1] + v2[1])
346      # extend the vectors and set the last layer to the set just added
347      vecs = vecs | new_vecs
348      last_vecs = new_vecs
349    if not include_0:  # throw away the identity vector
350      vecs.pop((0, 0, 0) if three_vecs else (0, 0))
351    ids, tiles = [], []
352    # we need to add the translated prototiles in order of their distance from
353    # tile 0, esp. in the square case, i.e. something like this:
354    #
355    #      5 4 3 4 5
356    #      4 2 1 2 4
357    #      3 1 0 1 3
358    #      4 2 1 2 4
359    #      5 4 3 4 5
360    #
361    # this is important for topology detection, where filtering back to the
362    # local patch of radius 1 is simplified if prototiles have been added in
363    # this order. We use the vector index tuples not the euclidean distances
364    # because this is more resistant to odd effects for non-convex tiles
365    extent = self.get_prototile_from_vectors().loc[0, "geometry"]
366    extent = affine.scale(extent,
367                          2 * r + tiling_utils.RESOLUTION,
368                          2 * r + tiling_utils.RESOLUTION)
369    vector_lengths = {index: np.sqrt(sum([_ ** 2 for _ in index]))
370                      for index in vecs}
371    ordered_vector_keys = [k for k, v in sorted(vector_lengths.items(),
372                                                key = lambda item: item[1])]
373    for k in ordered_vector_keys:
374      v = vecs[k]
375      if geom.Point(v[0], v[1]).within(extent):
376        ids.extend(self.tiles.tile_id)
377        tiles.extend(
378          self.tiles.geometry.apply(affine.translate, xoff = v[0], yoff = v[1]))
379    return gpd.GeoDataFrame(
380      data = {"tile_id": ids}, crs=self.crs,
381      geometry = gpd.GeoSeries(tiles))
382
383
384  # applicable to both TileUnits and WeaveUnits
385  def inset_tiles(
386      self,
387      inset:float = 0) -> "Tileable":
388    """Return a new Tileable with an inset applied around the tiles.
389
390    Works by applying a negative buffer of specfied size to all tiles.
391    Tiles that collapse to zero area are removed and the tile_id
392    attribute updated accordingly.
393
394    NOTE: this method is likely to not preserve the relative area of tiles.
395
396    Args:
397      inset (float, optional): The distance to inset. Defaults to `0`.
398
399    Returns:
400      "Tileable": the new inset Tileable.
401
402    """
403    inset_tiles, inset_ids = [], []
404    for p, ID in zip(self.tiles.geometry, self.tiles.tile_id, strict = True):
405      b = p.buffer(-inset, join_style = "mitre", cap_style = "square")
406      if not b.area <= 0:
407        inset_tiles.append(b)
408        inset_ids.append(ID)
409    result = copy.deepcopy(self)
410    result.tiles = gpd.GeoDataFrame(
411      data={"tile_id": inset_ids},
412      crs=self.crs,
413      geometry=gpd.GeoSeries(inset_tiles),
414    )
415    return result
416
417
418  def scale_tiles(
419      self,
420      sf:float = 1,
421      individually:bool = False,
422    ) -> "Tileable":
423    """Scales the tiles by the specified factor, centred on (0, 0).
424
425    Args:
426      sf (float, optional): scale factor to apply. Defaults to 1.
427      individually (bool, optional): if True scaling is applied to each tiling
428        element centred on its centre, rather than with respect to the Tileable.
429        Defaults to False.
430
431    Returns:
432      TileUnit: the scaled TileUnit.
433
434    """
435    if individually:
436      self.tiles.geometry = gpd.GeoSeries(
437        [affine.scale(g, sf, sf) for g in self.tiles.geometry])
438    else:
439      self.tiles.geometry = self.tiles.geometry.scale(sf, sf, origin = (0, 0))
440    return self
441
442
443  def transform_scale(
444      self,
445      xscale:float = 1.0,
446      yscale:float = 1.0,
447      independent_of_tiling:bool = False,
448    ) -> "Tileable":
449    """Transform tileable by scaling.
450
451    Args:
452      xscale (float, optional): x scale factor. Defaults to 1.0.
453      yscale (float, optional): y scale factor. Defaults to 1.0.
454      independent_of_tiling (bool, optional): if True Tileable is scaled while
455        leaving the translation vectors untouched, so that it can change size
456        independent from its spacing when tiled. Defaults to False.
457
458    Returns:
459      Tileable: the transformed Tileable.
460
461    """
462    result = copy.deepcopy(self)
463    result.tiles.geometry = self.tiles.geometry.scale(
464      xscale, yscale, origin=(0, 0))
465    if not independent_of_tiling:
466      result.prototile.geometry = self.prototile.geometry.scale(
467        xscale, yscale, origin=(0, 0))
468      result.regularised_prototile.geometry = \
469        self.regularised_prototile.geometry.scale(xscale, yscale, origin=(0, 0))
470      result._set_vectors_from_prototile()
471    return result
472
473
474  def transform_rotate(
475      self,
476      angle:float = 0.0,
477      independent_of_tiling:bool = False,
478    ) -> "Tileable":
479    """Transform tiling by rotation.
480
481    Args:
482      angle (float, optional): angle to rotate by. Defaults to 0.0.
483      independent_of_tiling (bool, optional): if True Tileable is rotated while
484        leaving the translation vectors untouched, so that it can change
485        orientation independent from its position when tiled. Defaults to False.
486
487    Returns:
488      Tileable: the transformed Tileable.
489
490    """
491    result = copy.deepcopy(self)
492    result.tiles.geometry = self.tiles.geometry.rotate(angle, origin=(0, 0))
493    if not independent_of_tiling:
494      result.prototile.geometry = \
495        self.prototile.geometry.rotate(angle, origin=(0, 0))
496      result.regularised_prototile.geometry = \
497        self.regularised_prototile.geometry.rotate(angle, origin=(0, 0))
498      result._set_vectors_from_prototile()
499      result.rotation = self.rotation + angle
500    return result
501
502
503  def transform_skew(
504      self,
505      xa:float = 0.0,
506      ya:float = 0.0,
507      independent_of_tiling:bool = False,
508      rescale = True
509    ) -> "Tileable":
510    """Transform tiling by skewing.
511
512    Args:
513      xa (float, optional): x direction skew. Defaults to 0.0.
514      ya (float, optional): y direction skew. Defaults to 0.0.
515      independent_of_tiling (bool, optional): if True Tileable is skewed while
516        leaving the translation vectors untouched, so that it can change shape
517        independent from its situation when tiled. Defaults to False.
518      rescale (bool, optional): if True rescales the result so that overall
519        size and extent is more or less unaffected by the skew, otherwise the
520        simultaneous application of both x and y shears may dramatically alter
521        the size of tiling. Defaults to True.
522
523    Returns:
524      Tileable: the transformed Tileable.
525
526    """
527    result = copy.deepcopy(self)
528    area_0 = result.prototile.geometry[0].area
529    result.tiles.geometry = (self.tiles.geometry
530                             .skew(xa, ya, origin=(0, 0)))
531    if not independent_of_tiling:
532      result.prototile.geometry = (self.prototile.geometry
533                                   .skew(xa, ya, origin=(0, 0)))
534      result.regularised_prototile.geometry = (
535        self.regularised_prototile.geometry
536            .skew(xa, ya, origin=(0, 0)))
537      result._set_vectors_from_prototile()
538    if rescale and xa != 0 and ya != 0:
539      area_1 = result.prototile.geometry[0].area
540      sf = np.sqrt(area_0 / area_1)
541      return result.transform_scale(sf, sf, independent_of_tiling)
542    else:
543      return result
544
545
546  def _set_vectors_from_prototile(self) -> None:
547    """Set translation vectors by derivation from a prototile shape.
548
549    Intended to be used internally, after a transform_scale, _skew, or _rotate.
550
551    These are 'face to face' vectors of the prototile, so that a hexagonal tile
552    will have 3 vectors, not the minimal parallelogram pair. Also sets the
553    inverse vectors. See `Tileable.setup_vectors()` for details.
554    """
555    t = self.prototile.loc[0, "geometry"]
556    points = list(t.exterior.coords)[:-1] # each point once only no wrap
557    n_pts = len(points)
558    vec_dict = {}
559    if n_pts == 4:
560      vecs = [(q[0] - p[0], q[1] - p[1])
561          for p, q in zip(points, points[1:] + points[:1], strict = True)]
562      i = [1, 0, -1,  0]
563      j = [0, 1,  0, -1]
564      vec_dict = {(i, j): v for i, j, v in zip(i, j, vecs, strict = True)}
565    elif n_pts == 6:
566      vecs = [(q[0] - p[0], q[1] - p[1])
567          for p, q in zip(points, points[2:] + points[:2], strict = True)]
568      # hex grid coordinates associated with each of the vectors
569      i = [ 0,  1,  1,  0, -1, -1]
570      j = [ 1,  0, -1, -1,  0,  1]
571      k = [-1, -1,  0,  1,  1,  0]
572      vec_dict = {
573        (i, j, k): v for i, j, k, v in zip(i, j, k, vecs, strict = True)}
574    self.vectors = vec_dict
575
576
577  def _get_mean_translation(self):
578    return np.mean(
579      [np.sqrt(dx ** 2 + dy ** 2) for (dx, dy) in self.get_vectors()])
580
581
582  def plot(
583      self,
584      ax:plt.Axes = None,
585      show_prototile:bool = True,
586      show_reg_prototile:bool = True,
587      show_ids:str|bool = "tile_id",
588      show_vectors:bool = False,
589      r:int = 0,
590      prototile_edgecolour:str = "k",
591      reg_prototile_edgecolour:str = "r",
592      vector_edgecolour:str = "k",
593      alpha:float = 1.0,
594      r_alpha:float = 0.5,
595      cmap:list[str]|str|None = None,
596      figsize:tuple[float] = (8, 8),
597      **kwargs,                        # noqa: ANN003
598    ) -> plt.Axes:
599    """Plot Tileable on the supplied axis.
600
601    **kwargs are passed on to matplotlib.plot()
602
603    Args:
604      ax (_type_, optional): matplotlib axis to draw to. Defaults to None.
605      show_prototile (bool, optional): if `True` show the tile outline.
606        Defaults to `True`.
607      show_reg_prototile (bool, optional): if `True` show the regularised tile
608        outline. Defaults to `True`.
609      show_ids (str, optional): if `tile_id` show the tile_ids. If
610        `id` show index number. If None or `''` don't label tiles.
611        Defaults to `tile_id`.
612      show_vectors (bool, optional): if `True` show the translation
613        vectors (not the minimal pair, but those used by
614        `get_local_patch()`). Defaults to `False`.
615      r (int, optional): passed to `get_local_patch()` to show context if
616        greater than 0. Defaults to `0`.
617      r_alpha (float, optional): alpha setting for units other than the
618        central one. Defaults to 0.5.
619      prototile_edgecolour (str, optional): outline colour for the tile.
620        Defaults to `"k"`.
621      reg_prototile_edgecolour (str, optional): outline colour for the
622        regularised. Defaults to `"r"`.
623      vector_edgecolour (str, optional): colour for the translation vectors.
624        Defaults to `"k"`.
625      cmap (list[str], optional): colour map to apply to the central
626        tiles. Defaults to `None`.
627      figsize (tuple[float], optional): size of the figure.
628        Defaults to `(8, 8)`.
629
630    Returns:
631      pyplot.axes: to which calling context may add things.
632
633    """
634    w = self.prototile.loc[0, "geometry"].bounds[2] - \
635      self.prototile.loc[0, "geometry"].bounds[0]
636    n_cols = len(set(self.tiles.tile_id))
637    if n_cols > 12:
638      cm = "Spectral"
639    elif n_cols > 8:
640      cm = "Paired"
641    else:
642      cm = "Dark2"
643    if ax is None:
644      ax = self.tiles.plot(
645        column="tile_id", cmap=cm, figsize=figsize, alpha = alpha, **kwargs)
646    else:
647      self.tiles.plot(
648        ax=ax, column="tile_id", cmap=cm, figsize=figsize, **kwargs)
649    if show_ids not in [None, ""]:
650      do_label = True
651      if show_ids in ["tile_id", True]:
652        labels = self.tiles.tile_id
653      elif show_ids == "id":
654        labels = [str(i) for i in range(self.tiles.shape[0])]
655      else:
656        do_label = False
657      if do_label:
658        for ID, tile in zip(labels, self.tiles.geometry, strict = True):
659          ax.annotate(ID, (tile.centroid.x, tile.centroid.y),
660            ha = "center", va = "center", bbox = {"lw": 0, "fc": "#ffffff40"})
661    if r > 0:
662      self.get_local_patch(r=r).plot(
663        ax = ax, column = "tile_id", alpha = r_alpha, cmap = cm, **kwargs)
664    if show_prototile:
665      self.prototile.plot(ax = ax, ec = prototile_edgecolour, lw = 0.5,
666                          fc = "#00000000", **kwargs)
667    if show_vectors:  # note that arrows in mpl are dimensioned in plotspace
668      vecs = self.get_vectors()
669      for v in vecs[: len(vecs) // 2]:
670        ax.arrow(0, 0, v[0], v[1], color = vector_edgecolour, width = w * 0.002,
671          head_width = w * 0.05, length_includes_head = True, zorder = 3)
672    if show_reg_prototile:
673      self.regularised_prototile.plot(
674        ax = ax, ec = reg_prototile_edgecolour, fc = "#00000000",
675        lw = 1.5, zorder = 2, **kwargs)
676    return ax
677
678
679  def _get_legend_tiles(self) -> gpd.GeoDataFrame:
680    """Return the tiles augmented by a rotation column.
681
682    This base implementation may be overridden by specific tile unit types.
683    In particular see `weavingspace.weave_unit.WeaveUnit._get_legend_tiles()`.
684
685    Returns:
686      gpd.GeoDataFrame: the tiles GeoDataFrame with a rotation column added.
687
688    """
689    tiles = copy.deepcopy(self.tiles)
690    tiles["rotation"] = 0
691    return tiles
692
693
694  def _setup_default_tileable(self) -> None:
695    """Set up a default Tileable for when the TileUnit generators fail."""
696    # if we've somehow got to here without a base_shape, make it rectangular
697    if self.base_shape is None:
698      self.base_shape = TileShape.RECTANGLE
699    if self.spacing is None:
700      self.spacing = 1000
701    match self.base_shape:
702      case TileShape.HEXAGON:
703        ids = ["a"]
704        tiles = [tiling_utils.get_regular_polygon(self.spacing, 6)]
705        self.setup_vectors(*self._get_hex_vectors())
706      case TileShape.DIAMOND:
707        ids = ["a"]
708        tiles = [( self.spacing/2, 0), (0,  self.spacing * np.sqrt(3)/2),
709                 (-self.spacing/2, 0), (0, -self.spacing * np.sqrt(3)/2)]
710        self.setup_vectors(*self._get_diamond_vectors())
711      case TileShape.TRIANGLE:
712        ids = ["a", "b"]
713        t1 = tiling_utils.get_regular_polygon(self.spacing, 3)
714        t1 = affine.translate(t1, 0, -t1.bounds[1])
715        t2 = affine.rotate(t1, 180, origin = (0, 0))
716        tiles = [t1, t2]
717        self.setup_vectors(*self._get_diamond_vectors())
718      case _:
719        ids = ["a"]
720        tiles = [tiling_utils.get_regular_polygon(self.spacing, 4)]
721        self.setup_vectors(*self._get_square_vectors())
722    self.tiles = gpd.GeoDataFrame(data = {"tile_id": ids},
723                                  geometry = gpd.GeoSeries(tiles),
724                                  crs = 3857)
725    self.crs = 3857
726    self.prototile = self.get_prototile_from_vectors()
727    self.regularised_prototile = copy.deepcopy(self.prototile)
728
729
730  def _get_hex_vectors(self) -> list[tuple[float,float]]:
731    """Return three vectors for a hexagonal tiling.
732
733    Returns:
734        list[tuple[float]]: Translation vectors of a hexagonal tiling.
735
736    """
737    return [(v[0] * self.spacing, v[1] * self.spacing)
738            for v in [(0, 1), (np.sqrt(3)/2, 1/2), (np.sqrt(3)/2, -1/2)]]
739
740
741  def _get_square_vectors(self) -> list[tuple[float,float]]:
742    """Return two vectors for a square tiling.
743
744    Returns:
745        list[tuple[float]]: Translation vectors of a square tiling.
746
747    """
748    return [(v[0] * self.spacing, v[1] * self.spacing)
749            for v in [(1, 0), (0, 1)]]
750
751
752  def _get_diamond_vectors(self) -> list[tuple[float,float]]:
753    """Return two vectors for a diamond or triangular tiling.
754
755    Returns:
756        list[tuple[float]]: Translation vectors of a square tiling.
757
758    """
759    return [(v[0] * self.spacing, v[1] * self.spacing)
760            for v in [(1/np.sqrt(3), 1), (1/np.sqrt(3), -1)]]

Class to represent a tileable set of tile geometries.

Tileable(**kwargs)
67  def __init__(self,      # noqa: D107
68               **kwargs,  # noqa: ANN003
69              ) -> None:
70    for k, v in kwargs.items():
71      if isinstance(v, str):
72        # make any string arguments lower case
73        self.__dict__[k] = v.lower()
74      else:
75        self.__dict__[k] = v
76    # delegate making the tiles back to the subclass _setup_tiles() method
77    # which is implemented differently by TileUnit and WeavUnit. It will return
78    # a message if there's a problem.
79    # There might be a try... except... way to do this more 'properly', but we
80    # prefer to return something even if it's not what was requested - along
81    # with an explanation / suggestion
82    message = self._setup_tiles()
83    if message is not None: # there was a problem
84      print(message)
85      self._setup_default_tileable()
86    else:
87      self.prototile = self.get_prototile_from_vectors()
88      self._setup_regularised_prototile()
tiles: geopandas.geodataframe.GeoDataFrame | None = None

the geometries with associated title_id attribute encoding their different colouring.

prototile: geopandas.geodataframe.GeoDataFrame | None = None

the tileable polygon (rectangle, hexagon or diamond)

spacing: float = 1000.0

the tile spacing effectively the resolution of the tiling. Defaults to 1000

base_shape: TileShape = <TileShape.RECTANGLE: 'rectangle'>

the tile shape. Defaults to 'RECTANGLE'

vectors: dict[tuple[int, ...], tuple[float, ...]] | None = None

translation vector symmetries of the tiling

regularised_prototile: geopandas.geodataframe.GeoDataFrame | None = None

polygon containing the tiles of this tileable, usually a union of its tile polygons

crs: int = 3857

coordinate reference system of the tile. Most often an ESPG code but any valid geopandas CRS specification is valid. Defaults to 3857 (i.e. Web Mercator).

rotation: float = 0.0

cumulative rotation of the tileable.

debug: bool = False

if True prints debug messages. Defaults to False.

def setup_vectors(self, *args) -> None:
 91  def setup_vectors(
 92        self,
 93        *args,  # noqa: ANN002
 94      ) -> None:
 95    """Set up translation vectors of a Tileable.
 96
 97    Initialised from either two or three supplied tuples. Two non-parallel
 98    vectors are sufficient for a tiling to work, but usually three will be
 99    supplied for tiles with a hexagonal base tile. We also store the reverse
100    vectors - this is for convenience when finding a 'local patch'. This method
101    is preferred during Tileable initialisation.
102
103    The vectors are stored in a dictionary indexed by their
104    coordinates, e.g.
105
106      {( 1,  0): ( 100, 0), ( 0,  1): (0,  100),
107       (-1,  0): (-100, 0), ( 0, -1): (0, -100)}
108
109    For a tileable of type `TileShape.HEXAGON`, the indexing tuples
110    have three components. See https://www.redblobgames.com/grids/hexagons/
111    """
112    vectors = list(args)
113    # extend list to include the inverse vectors too
114    for v in args:
115      vectors = [*vectors, (-v[0], -v[1])]
116    if len(vectors) == 4:
117      i = [1, 0, -1,  0]
118      j = [0, 1,  0, -1]
119      self.vectors = {
120        (i, j): v for i, j, v in zip(i, j, vectors, strict = True)}
121    else:
122      i = [ 0,  1,  1,  0, -1, -1]
123      j = [ 1,  0, -1, -1,  0,  1]
124      k = [-1, -1,  0,  1,  1,  0]
125      self.vectors = {
126        (i, j, k): v for i, j, k, v in zip(i, j, k, vectors, strict = True)}

Set up translation vectors of a Tileable.

Initialised from either two or three supplied tuples. Two non-parallel vectors are sufficient for a tiling to work, but usually three will be supplied for tiles with a hexagonal base tile. We also store the reverse vectors - this is for convenience when finding a 'local patch'. This method is preferred during Tileable initialisation.

The vectors are stored in a dictionary indexed by their coordinates, e.g.

{( 1, 0): ( 100, 0), ( 0, 1): (0, 100), (-1, 0): (-100, 0), ( 0, -1): (0, -100)}

For a tileable of type TileShape.HEXAGON, the indexing tuples have three components. See https://www.redblobgames.com/grids/hexagons/

def get_vectors( self, as_dict: bool = False) -> list[tuple[float, ...]] | dict[tuple[int, ...], tuple[float, ...]]:
129  def get_vectors(
130      self,
131      as_dict: bool = False,
132      ) -> list[tuple[float,...]]|dict[tuple[int,...],tuple[float,...]]:
133    """Return symmetry translation vectors as floating point pairs.
134
135    Optionally returns the vectors in a dictionary indexed by offsets in grid
136    coordinates, e.g.
137
138      {( 1,  0): ( 100, 0), ( 0,  1): (0,  100),
139       (-1,  0): (-100, 0), ( 0, -1): (0, -100)}
140
141    Returns:
142      dict[tuple[int],tuple[float]]|list[tuple[float]]: either the vectors as a
143        list of float tuples, or a dictionary of those vectors indexed by
144        integer coordinate tuples.
145
146    """
147    if as_dict:
148      return self.vectors
149    return list(self.vectors.values())

Return symmetry translation vectors as floating point pairs.

Optionally returns the vectors in a dictionary indexed by offsets in grid coordinates, e.g.

{( 1, 0): ( 100, 0), ( 0, 1): (0, 100), (-1, 0): (-100, 0), ( 0, -1): (0, -100)}

Returns:

dict[tuple[int],tuple[float]]|list[tuple[float]]: either the vectors as a list of float tuples, or a dictionary of those vectors indexed by integer coordinate tuples.

def get_prototile_from_vectors(self) -> geopandas.geodataframe.GeoDataFrame:
152  def get_prototile_from_vectors(self) -> gpd.GeoDataFrame:
153    r"""Contruct and returns a prototile unit based on vectors of the Tileable.
154
155    For rectangular tilings the prototile is formed by points
156    at diagonal corners defined by the halved vectors. By inspection, each edge
157    of the prototile is the resultant of adding two of the four vectors.
158
159      ----
160     |\  /|
161     | \/ |
162     | /\ |
163     |/  \|
164      ----
165
166    In the hexagonal case we form three such quadrilaterals (but don't halve the
167    vectors, because we need the extended length) and intersect them to find a
168    hexagonal shape. This guarantees that each vector will connect two opposite
169    faces of the hexagon, as desired. This seems the most elegant approach by
170    geometric construction.
171
172    The prototile is not uniquely defined. The shape returned by this method is
173    not guaranteed to be the most 'obvious' one that a human might construct!
174
175    Returns:
176      gpd.GeoDataFrame: A suitable prototile shape for the tiling wrapped in a
177        GeoDataFrame.
178
179    """
180    vecs = self.get_vectors()
181    if len(vecs) == 4:
182      v1, v2, v3, v4 = [(x / 2, y / 2) for (x, y) in vecs]
183      prototile = geom.Polygon([(v1[0] + v2[0], v1[1] + v2[1]),
184                                (v2[0] + v3[0], v2[1] + v3[1]),
185                                (v3[0] + v4[0], v3[1] + v4[1]),
186                                (v4[0] + v1[0], v4[1] + v1[1])])
187    else:
188      v1, v2, v3, v4, v5, v6 = vecs
189      q1 = geom.Polygon([v1, v2, v4, v5])
190      q2 = geom.Polygon([v2, v3, v5, v6])
191      q3 = geom.Polygon([v3, v4, v6, v1])
192      prototile = q3.intersection(q2).intersection(q1)
193    return gpd.GeoDataFrame(
194      geometry = gpd.GeoSeries([prototile]),
195      crs = self.crs)

Contruct and returns a prototile unit based on vectors of the Tileable.

For rectangular tilings the prototile is formed by points at diagonal corners defined by the halved vectors. By inspection, each edge of the prototile is the resultant of adding two of the four vectors.


|\ /| | \/ | | /\ | |/ \|


In the hexagonal case we form three such quadrilaterals (but don't halve the vectors, because we need the extended length) and intersect them to find a hexagonal shape. This guarantees that each vector will connect two opposite faces of the hexagon, as desired. This seems the most elegant approach by geometric construction.

The prototile is not uniquely defined. The shape returned by this method is not guaranteed to be the most 'obvious' one that a human might construct!

Returns:

gpd.GeoDataFrame: A suitable prototile shape for the tiling wrapped in a GeoDataFrame.

def get_local_patch( self, r: int = 1, include_0: bool = False) -> geopandas.geodataframe.GeoDataFrame:
307  def get_local_patch(
308      self,
309      r:int = 1,
310      include_0:bool = False,
311    ) -> gpd.GeoDataFrame:
312    """Return a GeoDataFrame with translated copies of the Tileable.
313
314    The geodataframe takes the same form as the `Tileable.tile` attribute.
315
316    Args:
317      r (int, optional): the number of 'layers' out from the unit to
318        which the translate copies will extendt. Defaults to `1`.
319      include_0 (bool, optional): If True includes the Tileable itself at
320        (0, 0). Defaults to `False`.
321
322    Returns:
323      gpd.GeoDataFrame: A GeoDataframe of the tiles extended by a number
324        of 'layers'.
325
326    """
327    # a dictionary of all the vectors we need, starting with (0, 0)
328    three_vecs = len(next(iter(self.vectors.keys()))) == 3
329    vecs = {(0, 0, 0): (0, 0)} if three_vecs else {(0, 0): (0, 0)}
330    steps = r if three_vecs else r * 2
331    # a dictionary of the last 'layer' of added vectors
332    last_vecs = copy.deepcopy(vecs)
333    # get the translation vectors in a dictionary indexed by coordinates
334    # we keep track of the sum of vectors using the (integer) coordinates
335    # to avoid duplication of moves due to floating point inaccuracies
336    vectors = self.get_vectors(as_dict = True)
337    for i in range(steps):
338      new_vecs = {}
339      for k1, v1 in last_vecs.items():
340        for k2, v2 in vectors.items():
341          # add the coordinates to make a new key...
342          new_key = tuple([k1[i] + k2[i] for i in range(len(k1))])
343          # if we haven't reached here before store the actual vector
344          if new_key not in vecs:
345            new_vecs[new_key] = (v1[0] + v2[0], v1[1] + v2[1])
346      # extend the vectors and set the last layer to the set just added
347      vecs = vecs | new_vecs
348      last_vecs = new_vecs
349    if not include_0:  # throw away the identity vector
350      vecs.pop((0, 0, 0) if three_vecs else (0, 0))
351    ids, tiles = [], []
352    # we need to add the translated prototiles in order of their distance from
353    # tile 0, esp. in the square case, i.e. something like this:
354    #
355    #      5 4 3 4 5
356    #      4 2 1 2 4
357    #      3 1 0 1 3
358    #      4 2 1 2 4
359    #      5 4 3 4 5
360    #
361    # this is important for topology detection, where filtering back to the
362    # local patch of radius 1 is simplified if prototiles have been added in
363    # this order. We use the vector index tuples not the euclidean distances
364    # because this is more resistant to odd effects for non-convex tiles
365    extent = self.get_prototile_from_vectors().loc[0, "geometry"]
366    extent = affine.scale(extent,
367                          2 * r + tiling_utils.RESOLUTION,
368                          2 * r + tiling_utils.RESOLUTION)
369    vector_lengths = {index: np.sqrt(sum([_ ** 2 for _ in index]))
370                      for index in vecs}
371    ordered_vector_keys = [k for k, v in sorted(vector_lengths.items(),
372                                                key = lambda item: item[1])]
373    for k in ordered_vector_keys:
374      v = vecs[k]
375      if geom.Point(v[0], v[1]).within(extent):
376        ids.extend(self.tiles.tile_id)
377        tiles.extend(
378          self.tiles.geometry.apply(affine.translate, xoff = v[0], yoff = v[1]))
379    return gpd.GeoDataFrame(
380      data = {"tile_id": ids}, crs=self.crs,
381      geometry = gpd.GeoSeries(tiles))

Return a GeoDataFrame with translated copies of the Tileable.

The geodataframe takes the same form as the Tileable.tile attribute.

Arguments:
  • r (int, optional): the number of 'layers' out from the unit to which the translate copies will extendt. Defaults to 1.
  • include_0 (bool, optional): If True includes the Tileable itself at (0, 0). Defaults to False.
Returns:

gpd.GeoDataFrame: A GeoDataframe of the tiles extended by a number of 'layers'.

def inset_tiles(self, inset: float = 0) -> Tileable:
385  def inset_tiles(
386      self,
387      inset:float = 0) -> "Tileable":
388    """Return a new Tileable with an inset applied around the tiles.
389
390    Works by applying a negative buffer of specfied size to all tiles.
391    Tiles that collapse to zero area are removed and the tile_id
392    attribute updated accordingly.
393
394    NOTE: this method is likely to not preserve the relative area of tiles.
395
396    Args:
397      inset (float, optional): The distance to inset. Defaults to `0`.
398
399    Returns:
400      "Tileable": the new inset Tileable.
401
402    """
403    inset_tiles, inset_ids = [], []
404    for p, ID in zip(self.tiles.geometry, self.tiles.tile_id, strict = True):
405      b = p.buffer(-inset, join_style = "mitre", cap_style = "square")
406      if not b.area <= 0:
407        inset_tiles.append(b)
408        inset_ids.append(ID)
409    result = copy.deepcopy(self)
410    result.tiles = gpd.GeoDataFrame(
411      data={"tile_id": inset_ids},
412      crs=self.crs,
413      geometry=gpd.GeoSeries(inset_tiles),
414    )
415    return result

Return a new Tileable with an inset applied around the tiles.

Works by applying a negative buffer of specfied size to all tiles. Tiles that collapse to zero area are removed and the tile_id attribute updated accordingly.

NOTE: this method is likely to not preserve the relative area of tiles.

Arguments:
  • inset (float, optional): The distance to inset. Defaults to 0.
Returns:

"Tileable": the new inset Tileable.

def scale_tiles( self, sf: float = 1, individually: bool = False) -> Tileable:
418  def scale_tiles(
419      self,
420      sf:float = 1,
421      individually:bool = False,
422    ) -> "Tileable":
423    """Scales the tiles by the specified factor, centred on (0, 0).
424
425    Args:
426      sf (float, optional): scale factor to apply. Defaults to 1.
427      individually (bool, optional): if True scaling is applied to each tiling
428        element centred on its centre, rather than with respect to the Tileable.
429        Defaults to False.
430
431    Returns:
432      TileUnit: the scaled TileUnit.
433
434    """
435    if individually:
436      self.tiles.geometry = gpd.GeoSeries(
437        [affine.scale(g, sf, sf) for g in self.tiles.geometry])
438    else:
439      self.tiles.geometry = self.tiles.geometry.scale(sf, sf, origin = (0, 0))
440    return self

Scales the tiles by the specified factor, centred on (0, 0).

Arguments:
  • sf (float, optional): scale factor to apply. Defaults to 1.
  • individually (bool, optional): if True scaling is applied to each tiling element centred on its centre, rather than with respect to the Tileable. Defaults to False.
Returns:

TileUnit: the scaled TileUnit.

def transform_scale( self, xscale: float = 1.0, yscale: float = 1.0, independent_of_tiling: bool = False) -> Tileable:
443  def transform_scale(
444      self,
445      xscale:float = 1.0,
446      yscale:float = 1.0,
447      independent_of_tiling:bool = False,
448    ) -> "Tileable":
449    """Transform tileable by scaling.
450
451    Args:
452      xscale (float, optional): x scale factor. Defaults to 1.0.
453      yscale (float, optional): y scale factor. Defaults to 1.0.
454      independent_of_tiling (bool, optional): if True Tileable is scaled while
455        leaving the translation vectors untouched, so that it can change size
456        independent from its spacing when tiled. Defaults to False.
457
458    Returns:
459      Tileable: the transformed Tileable.
460
461    """
462    result = copy.deepcopy(self)
463    result.tiles.geometry = self.tiles.geometry.scale(
464      xscale, yscale, origin=(0, 0))
465    if not independent_of_tiling:
466      result.prototile.geometry = self.prototile.geometry.scale(
467        xscale, yscale, origin=(0, 0))
468      result.regularised_prototile.geometry = \
469        self.regularised_prototile.geometry.scale(xscale, yscale, origin=(0, 0))
470      result._set_vectors_from_prototile()
471    return result

Transform tileable by scaling.

Arguments:
  • xscale (float, optional): x scale factor. Defaults to 1.0.
  • yscale (float, optional): y scale factor. Defaults to 1.0.
  • independent_of_tiling (bool, optional): if True Tileable is scaled while leaving the translation vectors untouched, so that it can change size independent from its spacing when tiled. Defaults to False.
Returns:

Tileable: the transformed Tileable.

def transform_rotate( self, angle: float = 0.0, independent_of_tiling: bool = False) -> Tileable:
474  def transform_rotate(
475      self,
476      angle:float = 0.0,
477      independent_of_tiling:bool = False,
478    ) -> "Tileable":
479    """Transform tiling by rotation.
480
481    Args:
482      angle (float, optional): angle to rotate by. Defaults to 0.0.
483      independent_of_tiling (bool, optional): if True Tileable is rotated while
484        leaving the translation vectors untouched, so that it can change
485        orientation independent from its position when tiled. Defaults to False.
486
487    Returns:
488      Tileable: the transformed Tileable.
489
490    """
491    result = copy.deepcopy(self)
492    result.tiles.geometry = self.tiles.geometry.rotate(angle, origin=(0, 0))
493    if not independent_of_tiling:
494      result.prototile.geometry = \
495        self.prototile.geometry.rotate(angle, origin=(0, 0))
496      result.regularised_prototile.geometry = \
497        self.regularised_prototile.geometry.rotate(angle, origin=(0, 0))
498      result._set_vectors_from_prototile()
499      result.rotation = self.rotation + angle
500    return result

Transform tiling by rotation.

Arguments:
  • angle (float, optional): angle to rotate by. Defaults to 0.0.
  • independent_of_tiling (bool, optional): if True Tileable is rotated while leaving the translation vectors untouched, so that it can change orientation independent from its position when tiled. Defaults to False.
Returns:

Tileable: the transformed Tileable.

def transform_skew( self, xa: float = 0.0, ya: float = 0.0, independent_of_tiling: bool = False, rescale=True) -> Tileable:
503  def transform_skew(
504      self,
505      xa:float = 0.0,
506      ya:float = 0.0,
507      independent_of_tiling:bool = False,
508      rescale = True
509    ) -> "Tileable":
510    """Transform tiling by skewing.
511
512    Args:
513      xa (float, optional): x direction skew. Defaults to 0.0.
514      ya (float, optional): y direction skew. Defaults to 0.0.
515      independent_of_tiling (bool, optional): if True Tileable is skewed while
516        leaving the translation vectors untouched, so that it can change shape
517        independent from its situation when tiled. Defaults to False.
518      rescale (bool, optional): if True rescales the result so that overall
519        size and extent is more or less unaffected by the skew, otherwise the
520        simultaneous application of both x and y shears may dramatically alter
521        the size of tiling. Defaults to True.
522
523    Returns:
524      Tileable: the transformed Tileable.
525
526    """
527    result = copy.deepcopy(self)
528    area_0 = result.prototile.geometry[0].area
529    result.tiles.geometry = (self.tiles.geometry
530                             .skew(xa, ya, origin=(0, 0)))
531    if not independent_of_tiling:
532      result.prototile.geometry = (self.prototile.geometry
533                                   .skew(xa, ya, origin=(0, 0)))
534      result.regularised_prototile.geometry = (
535        self.regularised_prototile.geometry
536            .skew(xa, ya, origin=(0, 0)))
537      result._set_vectors_from_prototile()
538    if rescale and xa != 0 and ya != 0:
539      area_1 = result.prototile.geometry[0].area
540      sf = np.sqrt(area_0 / area_1)
541      return result.transform_scale(sf, sf, independent_of_tiling)
542    else:
543      return result

Transform tiling by skewing.

Arguments:
  • xa (float, optional): x direction skew. Defaults to 0.0.
  • ya (float, optional): y direction skew. Defaults to 0.0.
  • independent_of_tiling (bool, optional): if True Tileable is skewed while leaving the translation vectors untouched, so that it can change shape independent from its situation when tiled. Defaults to False.
  • rescale (bool, optional): if True rescales the result so that overall size and extent is more or less unaffected by the skew, otherwise the simultaneous application of both x and y shears may dramatically alter the size of tiling. Defaults to True.
Returns:

Tileable: the transformed Tileable.

def plot( self, ax: matplotlib.axes._axes.Axes = None, show_prototile: bool = True, show_reg_prototile: bool = True, show_ids: str | bool = 'tile_id', show_vectors: bool = False, r: int = 0, prototile_edgecolour: str = 'k', reg_prototile_edgecolour: str = 'r', vector_edgecolour: str = 'k', alpha: float = 1.0, r_alpha: float = 0.5, cmap: list[str] | str | None = None, figsize: tuple[float] = (8, 8), **kwargs) -> matplotlib.axes._axes.Axes:
582  def plot(
583      self,
584      ax:plt.Axes = None,
585      show_prototile:bool = True,
586      show_reg_prototile:bool = True,
587      show_ids:str|bool = "tile_id",
588      show_vectors:bool = False,
589      r:int = 0,
590      prototile_edgecolour:str = "k",
591      reg_prototile_edgecolour:str = "r",
592      vector_edgecolour:str = "k",
593      alpha:float = 1.0,
594      r_alpha:float = 0.5,
595      cmap:list[str]|str|None = None,
596      figsize:tuple[float] = (8, 8),
597      **kwargs,                        # noqa: ANN003
598    ) -> plt.Axes:
599    """Plot Tileable on the supplied axis.
600
601    **kwargs are passed on to matplotlib.plot()
602
603    Args:
604      ax (_type_, optional): matplotlib axis to draw to. Defaults to None.
605      show_prototile (bool, optional): if `True` show the tile outline.
606        Defaults to `True`.
607      show_reg_prototile (bool, optional): if `True` show the regularised tile
608        outline. Defaults to `True`.
609      show_ids (str, optional): if `tile_id` show the tile_ids. If
610        `id` show index number. If None or `''` don't label tiles.
611        Defaults to `tile_id`.
612      show_vectors (bool, optional): if `True` show the translation
613        vectors (not the minimal pair, but those used by
614        `get_local_patch()`). Defaults to `False`.
615      r (int, optional): passed to `get_local_patch()` to show context if
616        greater than 0. Defaults to `0`.
617      r_alpha (float, optional): alpha setting for units other than the
618        central one. Defaults to 0.5.
619      prototile_edgecolour (str, optional): outline colour for the tile.
620        Defaults to `"k"`.
621      reg_prototile_edgecolour (str, optional): outline colour for the
622        regularised. Defaults to `"r"`.
623      vector_edgecolour (str, optional): colour for the translation vectors.
624        Defaults to `"k"`.
625      cmap (list[str], optional): colour map to apply to the central
626        tiles. Defaults to `None`.
627      figsize (tuple[float], optional): size of the figure.
628        Defaults to `(8, 8)`.
629
630    Returns:
631      pyplot.axes: to which calling context may add things.
632
633    """
634    w = self.prototile.loc[0, "geometry"].bounds[2] - \
635      self.prototile.loc[0, "geometry"].bounds[0]
636    n_cols = len(set(self.tiles.tile_id))
637    if n_cols > 12:
638      cm = "Spectral"
639    elif n_cols > 8:
640      cm = "Paired"
641    else:
642      cm = "Dark2"
643    if ax is None:
644      ax = self.tiles.plot(
645        column="tile_id", cmap=cm, figsize=figsize, alpha = alpha, **kwargs)
646    else:
647      self.tiles.plot(
648        ax=ax, column="tile_id", cmap=cm, figsize=figsize, **kwargs)
649    if show_ids not in [None, ""]:
650      do_label = True
651      if show_ids in ["tile_id", True]:
652        labels = self.tiles.tile_id
653      elif show_ids == "id":
654        labels = [str(i) for i in range(self.tiles.shape[0])]
655      else:
656        do_label = False
657      if do_label:
658        for ID, tile in zip(labels, self.tiles.geometry, strict = True):
659          ax.annotate(ID, (tile.centroid.x, tile.centroid.y),
660            ha = "center", va = "center", bbox = {"lw": 0, "fc": "#ffffff40"})
661    if r > 0:
662      self.get_local_patch(r=r).plot(
663        ax = ax, column = "tile_id", alpha = r_alpha, cmap = cm, **kwargs)
664    if show_prototile:
665      self.prototile.plot(ax = ax, ec = prototile_edgecolour, lw = 0.5,
666                          fc = "#00000000", **kwargs)
667    if show_vectors:  # note that arrows in mpl are dimensioned in plotspace
668      vecs = self.get_vectors()
669      for v in vecs[: len(vecs) // 2]:
670        ax.arrow(0, 0, v[0], v[1], color = vector_edgecolour, width = w * 0.002,
671          head_width = w * 0.05, length_includes_head = True, zorder = 3)
672    if show_reg_prototile:
673      self.regularised_prototile.plot(
674        ax = ax, ec = reg_prototile_edgecolour, fc = "#00000000",
675        lw = 1.5, zorder = 2, **kwargs)
676    return ax

Plot Tileable on the supplied axis.

**kwargs are passed on to matplotlib.plot()

Arguments:
  • ax (_type_, optional): matplotlib axis to draw to. Defaults to None.
  • show_prototile (bool, optional): if True show the tile outline. Defaults to True.
  • show_reg_prototile (bool, optional): if True show the regularised tile outline. Defaults to True.
  • show_ids (str, optional): if tile_id show the tile_ids. If id show index number. If None or '' don't label tiles. Defaults to tile_id.
  • show_vectors (bool, optional): if True show the translation vectors (not the minimal pair, but those used by get_local_patch()). Defaults to False.
  • r (int, optional): passed to get_local_patch() to show context if greater than 0. Defaults to 0.
  • r_alpha (float, optional): alpha setting for units other than the central one. Defaults to 0.5.
  • prototile_edgecolour (str, optional): outline colour for the tile. Defaults to "k".
  • reg_prototile_edgecolour (str, optional): outline colour for the regularised. Defaults to "r".
  • vector_edgecolour (str, optional): colour for the translation vectors. Defaults to "k".
  • cmap (list[str], optional): colour map to apply to the central tiles. Defaults to None.
  • figsize (tuple[float], optional): size of the figure. Defaults to (8, 8).
Returns:

pyplot.axes: to which calling context may add things.