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    ) -> "Tileable":
508    """Transform tiling by skewing.
509
510    Args:
511      xa (float, optional): x direction skew. Defaults to 0.0.
512      ya (float, optional): y direction skew. Defaults to 0.0.
513      independent_of_tiling (bool, optional): if True Tileable is skewed while
514        leaving the translation vectors untouched, so that it can change shape
515        independent from its situation when tiled. Defaults to False.
516
517    Returns:
518      Tileable: the transformed Tileable.
519
520    """
521    result = copy.deepcopy(self)
522    result.tiles.geometry = self.tiles.geometry.skew(xa, ya, origin=(0, 0))
523    if not independent_of_tiling:
524      result.prototile.geometry = \
525        self.prototile.geometry.skew(xa, ya, origin=(0, 0))
526      result.regularised_prototile.geometry = \
527        self.regularised_prototile.geometry.skew(xa, ya, origin=(0, 0))
528      result._set_vectors_from_prototile()
529    return result
530
531
532  def _set_vectors_from_prototile(self) -> None:
533    """Set translation vectors by derivation from a prototile shape.
534
535    Intended to be used internally, after a transform_scale, _skew, or _rotate.
536
537    These are 'face to face' vectors of the prototile, so that a hexagonal tile
538    will have 3 vectors, not the minimal parallelogram pair. Also sets the
539    inverse vectors. See `Tileable.setup_vectors()` for details.
540    """
541    t = self.prototile.loc[0, "geometry"]
542    points = list(t.exterior.coords)[:-1] # each point once only no wrap
543    n_pts = len(points)
544    vec_dict = {}
545    if n_pts == 4:
546      vecs = [(q[0] - p[0], q[1] - p[1])
547          for p, q in zip(points, points[1:] + points[:1], strict = True)]
548      i = [1, 0, -1,  0]
549      j = [0, 1,  0, -1]
550      vec_dict = {(i, j): v for i, j, v in zip(i, j, vecs, strict = True)}
551    elif n_pts == 6:
552      vecs = [(q[0] - p[0], q[1] - p[1])
553          for p, q in zip(points, points[2:] + points[:2], strict = True)]
554      # hex grid coordinates associated with each of the vectors
555      i = [ 0,  1,  1,  0, -1, -1]
556      j = [ 1,  0, -1, -1,  0,  1]
557      k = [-1, -1,  0,  1,  1,  0]
558      vec_dict = {
559        (i, j, k): v for i, j, k, v in zip(i, j, k, vecs, strict = True)}
560    self.vectors = vec_dict
561
562
563  def plot(
564      self,
565      ax:plt.Axes = None,
566      show_prototile:bool = True,
567      show_reg_prototile:bool = True,
568      show_ids:str|bool = "tile_id",
569      show_vectors:bool = False,
570      r:int = 0,
571      prototile_edgecolour:str = "k",
572      reg_prototile_edgecolour:str = "r",
573      vector_edgecolour:str = "k",
574      alpha:float = 1.0,
575      r_alpha:float = 0.5,
576      cmap:list[str]|str|None = None,
577      figsize:tuple[float] = (8, 8),
578      **kwargs,                        # noqa: ANN003
579    ) -> plt.Axes:
580    """Plot Tileable on the supplied axis.
581
582    **kwargs are passed on to matplotlib.plot()
583
584    Args:
585      ax (_type_, optional): matplotlib axis to draw to. Defaults to None.
586      show_prototile (bool, optional): if `True` show the tile outline.
587        Defaults to `True`.
588      show_reg_prototile (bool, optional): if `True` show the regularised tile
589        outline. Defaults to `True`.
590      show_ids (str, optional): if `tile_id` show the tile_ids. If
591        `id` show index number. If None or `''` don't label tiles.
592        Defaults to `tile_id`.
593      show_vectors (bool, optional): if `True` show the translation
594        vectors (not the minimal pair, but those used by
595        `get_local_patch()`). Defaults to `False`.
596      r (int, optional): passed to `get_local_patch()` to show context if
597        greater than 0. Defaults to `0`.
598      r_alpha (float, optional): alpha setting for units other than the
599        central one. Defaults to 0.5.
600      prototile_edgecolour (str, optional): outline colour for the tile.
601        Defaults to `"k"`.
602      reg_prototile_edgecolour (str, optional): outline colour for the
603        regularised. Defaults to `"r"`.
604      vector_edgecolour (str, optional): colour for the translation vectors.
605        Defaults to `"k"`.
606      cmap (list[str], optional): colour map to apply to the central
607        tiles. Defaults to `None`.
608      figsize (tuple[float], optional): size of the figure.
609        Defaults to `(8, 8)`.
610
611    Returns:
612      pyplot.axes: to which calling context may add things.
613
614    """
615    w = self.prototile.loc[0, "geometry"].bounds[2] - \
616      self.prototile.loc[0, "geometry"].bounds[0]
617    n_cols = len(set(self.tiles.tile_id))
618    cm = ("Dark2" if n_cols <= 8 else "Paired") if cmap is None else cmap
619    if ax is None:
620      ax = self.tiles.plot(
621        column="tile_id", cmap=cm, figsize=figsize, alpha = alpha, **kwargs)
622    else:
623      self.tiles.plot(
624        ax=ax, column="tile_id", cmap=cm, figsize=figsize, **kwargs)
625    if show_ids not in [None, ""]:
626      do_label = True
627      if show_ids in ["tile_id", True]:
628        labels = self.tiles.tile_id
629      elif show_ids == "id":
630        labels = [str(i) for i in range(self.tiles.shape[0])]
631      else:
632        do_label = False
633      if do_label:
634        for ID, tile in zip(labels, self.tiles.geometry, strict = True):
635          ax.annotate(ID, (tile.centroid.x, tile.centroid.y),
636            ha = "center", va = "center", bbox = {"lw": 0, "fc": "#ffffff40"})
637    if r > 0:
638      self.get_local_patch(r=r).plot(
639        ax = ax, column = "tile_id", alpha = r_alpha, cmap = cm, **kwargs)
640    if show_prototile:
641      self.prototile.plot(ax = ax, ec = prototile_edgecolour, lw = 0.5,
642                          fc = "#00000000", **kwargs)
643    if show_vectors:  # note that arrows in mpl are dimensioned in plotspace
644      vecs = self.get_vectors()
645      for v in vecs[: len(vecs) // 2]:
646        ax.arrow(0, 0, v[0], v[1], color = vector_edgecolour, width = w * 0.002,
647          head_width = w * 0.05, length_includes_head = True, zorder = 3)
648    if show_reg_prototile:
649      self.regularised_prototile.plot(
650        ax = ax, ec = reg_prototile_edgecolour, fc = "#00000000",
651        lw = 1.5, zorder = 2, **kwargs)
652    return ax
653
654
655  def _get_legend_tiles(self) -> gpd.GeoDataFrame:
656    """Return the tiles augmented by a rotation column.
657
658    This base implementation may be overridden by specific tile unit types.
659    In particular see `weavingspace.weave_unit.WeaveUnit._get_legend_tiles()`.
660
661    Returns:
662      gpd.GeoDataFrame: the tiles GeoDataFrame with a rotation column added.
663
664    """
665    tiles = copy.deepcopy(self.tiles)
666    tiles["rotation"] = 0
667    return tiles
668
669
670  def _setup_default_tileable(self) -> None:
671    """Set up a default Tileable for when the TileUnit generators fail."""
672    # if we've somehow got to here without a base_shape, make it rectangular
673    if self.base_shape is None:
674      self.base_shape = TileShape.RECTANGLE
675    if self.spacing is None:
676      self.spacing = 1000
677    match self.base_shape:
678      case TileShape.HEXAGON:
679        ids = ["a"]
680        tiles = [tiling_utils.get_regular_polygon(self.spacing, 6)]
681        self.setup_vectors(*self._get_hex_vectors())
682      case TileShape.DIAMOND:
683        ids = ["a"]
684        tiles = [( self.spacing/2, 0), (0,  self.spacing * np.sqrt(3)/2),
685                 (-self.spacing/2, 0), (0, -self.spacing * np.sqrt(3)/2)]
686        self.setup_vectors(*self._get_diamond_vectors())
687      case TileShape.TRIANGLE:
688        ids = ["a", "b"]
689        t1 = tiling_utils.get_regular_polygon(self.spacing, 3)
690        t1 = affine.translate(t1, 0, -t1.bounds[1])
691        t2 = affine.rotate(t1, 180, origin = (0, 0))
692        tiles = [t1, t2]
693        self.setup_vectors(*self._get_diamond_vectors())
694      case _:
695        ids = ["a"]
696        tiles = [tiling_utils.get_regular_polygon(self.spacing, 4)]
697        self.setup_vectors(*self._get_square_vectors())
698    self.tiles = gpd.GeoDataFrame(data = {"tile_id": ids},
699                                  geometry = gpd.GeoSeries(tiles),
700                                  crs = 3857)
701    self.crs = 3857
702    self.prototile = self.get_prototile_from_vectors()
703    self.regularised_prototile = copy.deepcopy(self.prototile)
704
705
706  def _get_hex_vectors(self) -> list[tuple[float,float]]:
707    """Return three vectors for a hexagonal tiling.
708
709    Returns:
710        list[tuple[float]]: Translation vectors of a hexagonal tiling.
711
712    """
713    return [(v[0] * self.spacing, v[1] * self.spacing)
714            for v in [(0, 1), (np.sqrt(3)/2, 1/2), (np.sqrt(3)/2, -1/2)]]
715
716
717  def _get_square_vectors(self) -> list[tuple[float,float]]:
718    """Return two vectors for a square tiling.
719
720    Returns:
721        list[tuple[float]]: Translation vectors of a square tiling.
722
723    """
724    return [(v[0] * self.spacing, v[1] * self.spacing)
725            for v in [(1, 0), (0, 1)]]
726
727
728  def _get_diamond_vectors(self) -> list[tuple[float,float]]:
729    """Return two vectors for a diamond or triangular tiling.
730
731    Returns:
732        list[tuple[float]]: Translation vectors of a square tiling.
733
734    """
735    return [(v[0] * self.spacing, v[1] * self.spacing)
736            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    ) -> "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
518    Returns:
519      Tileable: the transformed Tileable.
520
521    """
522    result = copy.deepcopy(self)
523    result.tiles.geometry = self.tiles.geometry.skew(xa, ya, origin=(0, 0))
524    if not independent_of_tiling:
525      result.prototile.geometry = \
526        self.prototile.geometry.skew(xa, ya, origin=(0, 0))
527      result.regularised_prototile.geometry = \
528        self.regularised_prototile.geometry.skew(xa, ya, origin=(0, 0))
529      result._set_vectors_from_prototile()
530    return result
531
532
533  def _set_vectors_from_prototile(self) -> None:
534    """Set translation vectors by derivation from a prototile shape.
535
536    Intended to be used internally, after a transform_scale, _skew, or _rotate.
537
538    These are 'face to face' vectors of the prototile, so that a hexagonal tile
539    will have 3 vectors, not the minimal parallelogram pair. Also sets the
540    inverse vectors. See `Tileable.setup_vectors()` for details.
541    """
542    t = self.prototile.loc[0, "geometry"]
543    points = list(t.exterior.coords)[:-1] # each point once only no wrap
544    n_pts = len(points)
545    vec_dict = {}
546    if n_pts == 4:
547      vecs = [(q[0] - p[0], q[1] - p[1])
548          for p, q in zip(points, points[1:] + points[:1], strict = True)]
549      i = [1, 0, -1,  0]
550      j = [0, 1,  0, -1]
551      vec_dict = {(i, j): v for i, j, v in zip(i, j, vecs, strict = True)}
552    elif n_pts == 6:
553      vecs = [(q[0] - p[0], q[1] - p[1])
554          for p, q in zip(points, points[2:] + points[:2], strict = True)]
555      # hex grid coordinates associated with each of the vectors
556      i = [ 0,  1,  1,  0, -1, -1]
557      j = [ 1,  0, -1, -1,  0,  1]
558      k = [-1, -1,  0,  1,  1,  0]
559      vec_dict = {
560        (i, j, k): v for i, j, k, v in zip(i, j, k, vecs, strict = True)}
561    self.vectors = vec_dict
562
563
564  def plot(
565      self,
566      ax:plt.Axes = None,
567      show_prototile:bool = True,
568      show_reg_prototile:bool = True,
569      show_ids:str|bool = "tile_id",
570      show_vectors:bool = False,
571      r:int = 0,
572      prototile_edgecolour:str = "k",
573      reg_prototile_edgecolour:str = "r",
574      vector_edgecolour:str = "k",
575      alpha:float = 1.0,
576      r_alpha:float = 0.5,
577      cmap:list[str]|str|None = None,
578      figsize:tuple[float] = (8, 8),
579      **kwargs,                        # noqa: ANN003
580    ) -> plt.Axes:
581    """Plot Tileable on the supplied axis.
582
583    **kwargs are passed on to matplotlib.plot()
584
585    Args:
586      ax (_type_, optional): matplotlib axis to draw to. Defaults to None.
587      show_prototile (bool, optional): if `True` show the tile outline.
588        Defaults to `True`.
589      show_reg_prototile (bool, optional): if `True` show the regularised tile
590        outline. Defaults to `True`.
591      show_ids (str, optional): if `tile_id` show the tile_ids. If
592        `id` show index number. If None or `''` don't label tiles.
593        Defaults to `tile_id`.
594      show_vectors (bool, optional): if `True` show the translation
595        vectors (not the minimal pair, but those used by
596        `get_local_patch()`). Defaults to `False`.
597      r (int, optional): passed to `get_local_patch()` to show context if
598        greater than 0. Defaults to `0`.
599      r_alpha (float, optional): alpha setting for units other than the
600        central one. Defaults to 0.5.
601      prototile_edgecolour (str, optional): outline colour for the tile.
602        Defaults to `"k"`.
603      reg_prototile_edgecolour (str, optional): outline colour for the
604        regularised. Defaults to `"r"`.
605      vector_edgecolour (str, optional): colour for the translation vectors.
606        Defaults to `"k"`.
607      cmap (list[str], optional): colour map to apply to the central
608        tiles. Defaults to `None`.
609      figsize (tuple[float], optional): size of the figure.
610        Defaults to `(8, 8)`.
611
612    Returns:
613      pyplot.axes: to which calling context may add things.
614
615    """
616    w = self.prototile.loc[0, "geometry"].bounds[2] - \
617      self.prototile.loc[0, "geometry"].bounds[0]
618    n_cols = len(set(self.tiles.tile_id))
619    cm = ("Dark2" if n_cols <= 8 else "Paired") if cmap is None else cmap
620    if ax is None:
621      ax = self.tiles.plot(
622        column="tile_id", cmap=cm, figsize=figsize, alpha = alpha, **kwargs)
623    else:
624      self.tiles.plot(
625        ax=ax, column="tile_id", cmap=cm, figsize=figsize, **kwargs)
626    if show_ids not in [None, ""]:
627      do_label = True
628      if show_ids in ["tile_id", True]:
629        labels = self.tiles.tile_id
630      elif show_ids == "id":
631        labels = [str(i) for i in range(self.tiles.shape[0])]
632      else:
633        do_label = False
634      if do_label:
635        for ID, tile in zip(labels, self.tiles.geometry, strict = True):
636          ax.annotate(ID, (tile.centroid.x, tile.centroid.y),
637            ha = "center", va = "center", bbox = {"lw": 0, "fc": "#ffffff40"})
638    if r > 0:
639      self.get_local_patch(r=r).plot(
640        ax = ax, column = "tile_id", alpha = r_alpha, cmap = cm, **kwargs)
641    if show_prototile:
642      self.prototile.plot(ax = ax, ec = prototile_edgecolour, lw = 0.5,
643                          fc = "#00000000", **kwargs)
644    if show_vectors:  # note that arrows in mpl are dimensioned in plotspace
645      vecs = self.get_vectors()
646      for v in vecs[: len(vecs) // 2]:
647        ax.arrow(0, 0, v[0], v[1], color = vector_edgecolour, width = w * 0.002,
648          head_width = w * 0.05, length_includes_head = True, zorder = 3)
649    if show_reg_prototile:
650      self.regularised_prototile.plot(
651        ax = ax, ec = reg_prototile_edgecolour, fc = "#00000000",
652        lw = 1.5, zorder = 2, **kwargs)
653    return ax
654
655
656  def _get_legend_tiles(self) -> gpd.GeoDataFrame:
657    """Return the tiles augmented by a rotation column.
658
659    This base implementation may be overridden by specific tile unit types.
660    In particular see `weavingspace.weave_unit.WeaveUnit._get_legend_tiles()`.
661
662    Returns:
663      gpd.GeoDataFrame: the tiles GeoDataFrame with a rotation column added.
664
665    """
666    tiles = copy.deepcopy(self.tiles)
667    tiles["rotation"] = 0
668    return tiles
669
670
671  def _setup_default_tileable(self) -> None:
672    """Set up a default Tileable for when the TileUnit generators fail."""
673    # if we've somehow got to here without a base_shape, make it rectangular
674    if self.base_shape is None:
675      self.base_shape = TileShape.RECTANGLE
676    if self.spacing is None:
677      self.spacing = 1000
678    match self.base_shape:
679      case TileShape.HEXAGON:
680        ids = ["a"]
681        tiles = [tiling_utils.get_regular_polygon(self.spacing, 6)]
682        self.setup_vectors(*self._get_hex_vectors())
683      case TileShape.DIAMOND:
684        ids = ["a"]
685        tiles = [( self.spacing/2, 0), (0,  self.spacing * np.sqrt(3)/2),
686                 (-self.spacing/2, 0), (0, -self.spacing * np.sqrt(3)/2)]
687        self.setup_vectors(*self._get_diamond_vectors())
688      case TileShape.TRIANGLE:
689        ids = ["a", "b"]
690        t1 = tiling_utils.get_regular_polygon(self.spacing, 3)
691        t1 = affine.translate(t1, 0, -t1.bounds[1])
692        t2 = affine.rotate(t1, 180, origin = (0, 0))
693        tiles = [t1, t2]
694        self.setup_vectors(*self._get_diamond_vectors())
695      case _:
696        ids = ["a"]
697        tiles = [tiling_utils.get_regular_polygon(self.spacing, 4)]
698        self.setup_vectors(*self._get_square_vectors())
699    self.tiles = gpd.GeoDataFrame(data = {"tile_id": ids},
700                                  geometry = gpd.GeoSeries(tiles),
701                                  crs = 3857)
702    self.crs = 3857
703    self.prototile = self.get_prototile_from_vectors()
704    self.regularised_prototile = copy.deepcopy(self.prototile)
705
706
707  def _get_hex_vectors(self) -> list[tuple[float,float]]:
708    """Return three vectors for a hexagonal tiling.
709
710    Returns:
711        list[tuple[float]]: Translation vectors of a hexagonal tiling.
712
713    """
714    return [(v[0] * self.spacing, v[1] * self.spacing)
715            for v in [(0, 1), (np.sqrt(3)/2, 1/2), (np.sqrt(3)/2, -1/2)]]
716
717
718  def _get_square_vectors(self) -> list[tuple[float,float]]:
719    """Return two vectors for a square tiling.
720
721    Returns:
722        list[tuple[float]]: Translation vectors of a square tiling.
723
724    """
725    return [(v[0] * self.spacing, v[1] * self.spacing)
726            for v in [(1, 0), (0, 1)]]
727
728
729  def _get_diamond_vectors(self) -> list[tuple[float,float]]:
730    """Return two vectors for a diamond or triangular tiling.
731
732    Returns:
733        list[tuple[float]]: Translation vectors of a square tiling.
734
735    """
736    return [(v[0] * self.spacing, v[1] * self.spacing)
737            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.

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

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

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

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

Args: 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) -> Tileable:
503  def transform_skew(
504      self,
505      xa:float = 0.0,
506      ya:float = 0.0,
507      independent_of_tiling:bool = False,
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
518    Returns:
519      Tileable: the transformed Tileable.
520
521    """
522    result = copy.deepcopy(self)
523    result.tiles.geometry = self.tiles.geometry.skew(xa, ya, origin=(0, 0))
524    if not independent_of_tiling:
525      result.prototile.geometry = \
526        self.prototile.geometry.skew(xa, ya, origin=(0, 0))
527      result.regularised_prototile.geometry = \
528        self.regularised_prototile.geometry.skew(xa, ya, origin=(0, 0))
529      result._set_vectors_from_prototile()
530    return result

Transform tiling by skewing.

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

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

Plot Tileable on the supplied axis.

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

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