weavingspace.tileable

Implements TileShape and Tileable the base classes for weavingspace.tile_unit.TileUnit and weavingspace.weave_unit.WeaveUnit.

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

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

  1#!/usr/bin/env python
  2# coding: utf-8
  3
  4"""Implements `weavingspace.tileable.TileShape` and
  5`weavingspace.tileable.Tileable` the base classes for
  6`weavingspace.tile_unit.TileUnit` and `weavingspace.weave_unit.WeaveUnit`.
  7
  8`Tileable` should not be called directly, but is instead accessed from the
  9`weavingspace.tile_unit.TileUnit` or `weavingspace.weave_unit.WeaveUnit`
 10constructor.
 11
 12Several methods of `weavingspace.tileable.Tileable` are generally useful and
 13can be accessed through its subclasses.
 14"""
 15
 16from enum import Enum
 17from typing import Union
 18from dataclasses import dataclass
 19import copy
 20
 21import matplotlib.pyplot as pyplot
 22
 23import geopandas as gpd
 24import numpy as np
 25import shapely.geometry as geom
 26import shapely.affinity as affine
 27
 28import weavingspace.tiling_utils as tiling_utils
 29
 30
 31class TileShape(Enum):
 32  """The available base tile shapes.
 33
 34  NOTE: the TRIANGLE type does not persist, but should be converted to a
 35  DIAMOND or HEXAGON type during `Tileable` construction.
 36  """
 37
 38  RECTANGLE = "rectangle"
 39  HEXAGON = "hexagon"
 40  TRIANGLE = "triangle"
 41  DIAMOND = "diamond"
 42
 43
 44@dataclass
 45class Tileable:
 46  """Class to represent a tileable set of tile geometries.
 47  """
 48
 49  tiles: gpd.GeoDataFrame = None
 50  """the geometries with associated `title_id` attribute encoding their 
 51  different colouring."""
 52  prototile: gpd.GeoDataFrame = None
 53  """the tileable polygon (rectangle, hexagon or diamond)"""
 54  spacing: float = 1000.0
 55  """the tile spacing effectively the resolution of the tiling. Defaults to
 56  1000"""
 57  base_shape: TileShape = TileShape.RECTANGLE
 58  """the tile shape. Defaults to 'RECTANGLE'"""
 59  vectors: dict[tuple[int], tuple[float]] = None
 60  """translation vector symmetries of the tiling"""
 61  regularised_prototile: gpd.GeoDataFrame = None
 62  """polygon containing the tiles of this tileable, usually a union of its
 63  tile polygons"""
 64  crs: int = 3857
 65  """coordinate reference system of the tile. Most often an ESPG code but
 66  any valid geopandas CRS specification is valid. Defaults to 3857 (i.e. Web
 67  Mercator)."""
 68  rotation: float = 0.0
 69  """cumulative rotation of the tileable."""
 70  debug: bool = False
 71  """if True prints debug messages. Defaults to False."""
 72
 73  # Tileable constructor called by subclasses - should not be used directly
 74  def __init__(self, **kwargs):
 75    for k, v in kwargs.items():
 76      self.__dict__[k] = v
 77    if self.debug:
 78      print(
 79        f"""Debugging messages enabled for Tileable (but there aren't
 80          any at the moment...)"""
 81      )
 82    self._setup_tiles()
 83    self.setup_vectors()
 84    self._setup_regularised_prototile()
 85    return
 86
 87
 88  def setup_vectors(self) -> None:
 89    """Sets up the symmetry translation vectors as floating point pairs
 90    indexed by integer tuples with respect to either a rectangular or
 91    triangular grid location.
 92
 93    Derived from the size and shape of the tile attribute. These are not
 94    the minimal translation vectors, but the 'face to face' vectors of the
 95    tile, such that a hexagonal tile will have 3 vectors, not the minimal
 96    parallelogram pair. Also supplies the inverse vectors.
 97
 98    The vectors are stored in a dictionary indexed by their
 99    coordinates, e.g.
100
101      {( 1,  0): ( 100, 0), ( 0,  1): (0,  100),
102       (-1,  0): (-100, 0), ( 0, -1): (0, -100)}
103
104    For a tileable of type `TileShape.HEXAGON`, the indexing tuples
105    have three components. See https://www.redblobgames.com/grids/hexagons/
106    """
107    t = self.prototile.geometry[0]
108    pts = [p for p in t.exterior.coords][:-1]
109    n_pts = len(pts)
110    vec_dict = {}
111    if n_pts == 4:
112      vecs = [(q[0] - p[0], q[1] - p[1])
113          for p, q in zip(pts, pts[1:] + pts[:1])]
114      i = [1, 0, -1,  0]
115      j = [0, 1,  0, -1]
116      vec_dict = {(i, j): v for i, j, v in zip(i, j, vecs)}
117    elif n_pts == 6:
118      vecs = [(q[0] - p[0], q[1] - p[1])
119          for p, q in zip(pts, pts[2:] + pts[:2])]
120      # hex grid coordinates associated with each of the vectors
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      vec_dict = {(i, j, k): v for i, j, k, v in zip(i, j, k, vecs)}
125    self.vectors = vec_dict
126
127
128  def get_vectors(
129      self, as_dict: bool = False
130    ) -> Union[dict[tuple[int], tuple[float]], list[tuple[float]]]:
131    """
132    Returns symmetry translation vectors as floating point pairs.
133    Optionally returns the vectors in a dictionary indexed by their
134    coordinates, e.g.
135
136      {( 1,  0): ( 100, 0), ( 0,  1): (0,  100),
137       (-1,  0): (-100, 0), ( 0, -1): (0, -100)}
138
139    Returns:
140      Union[ dict[tuple[int],tuple[float]], list[tuple[float]] ]:
141        either the vectors as a list of float tuples, or a dictionary
142        of those vectors indexed by integer coordinate tuples.
143    """
144    if as_dict:
145      return self.vectors
146    else:
147      return list(self.vectors.values())
148
149
150  # Make up a regularised tile by carefully unioning the tiles
151  def setup_regularised_prototile_from_tiles(self) -> None:
152    """Sets the regularised tile to a union of the tiles."""
153    self.regularised_prototile = copy.deepcopy(self.prototile)
154    self.regularised_prototile.geometry = [tiling_utils.safe_union(
155      self.tiles.geometry, as_polygon = True)]
156    # This simplification seems very crude but fixes all kinds of issues...
157    # particularly with the triaxial weave units... where intersection 
158    # operations are prone to creating spurious vertices, etc.
159    # self.regularised_prototile.geometry[0] = \
160    #   self.regularised_prototile.geometry[0].simplify(
161    #     self.spacing * tiling_utils.RESOLUTION)
162    return
163
164
165  def merge_fragments(self, fragments:list[geom.Polygon]) -> list[geom.Polygon]:
166    """
167    Merges a set of polygons based on testing if they touch when subjected
168    to the translation vectors provided by `get_vectors()`.
169
170    Called by `regularise_tiles()` to combine tiles in a tile unit that
171    may be fragmented as supplied but will combine after tiling into single
172    tiles. This step makes for more efficient implementation of the
173    tiling of map regions.
174
175    Args:
176      fragments (list[geom.Polygon]): A set of polygons to merge.
177
178    Returns:
179      list[geom.Polygon]: A minimal list of merged polygons.
180    """
181    if len(fragments) == 1:
182      return [f for f in fragments if not f.is_empty]
183    fragments = [f for f in fragments if not f.is_empty]
184    prototile = self.prototile.geometry[0]
185    reg_prototile = copy.deepcopy(self.regularised_prototile.geometry[0])
186    changes_made = True
187    while changes_made:
188      changes_made = False
189      for v in self.vectors.values():
190        # empty list to collect the new fragments
191        # assembled in this iteration
192        next_frags = []
193        t_frags = [affine.translate(f, v[0], v[1]) for f in fragments]
194        # build a set of any near matching pairs of
195        # fragments and their translated copies
196        matches = set()
197        for i, f1 in enumerate(fragments):
198          for j, f2, in enumerate(t_frags):
199            if i < j and tiling_utils.touch_along_an_edge(f1, f2):
200              matches.add((i, j))
201        # determine which of these when unioned has the larger area in common # with the prototile
202        frags_to_remove = set()
203        for i, j in matches:
204          f1, f2 = fragments[i], t_frags[j]
205          u1 = f1.buffer(tiling_utils.RESOLUTION, join_style = 2, cap_style = 3).union(
206            f2.buffer(tiling_utils.RESOLUTION, join_style = 2, cap_style = 3))
207          u2 = affine.translate(u1, -v[0], -v[1])
208          if prototile.intersection(u1).area > prototile.intersection(u2).area:
209            u1 = u1.buffer(-tiling_utils.RESOLUTION, join_style = 2, cap_style = 3)
210            u2 = u2.buffer(-tiling_utils.RESOLUTION, join_style = 2, cap_style = 3)
211            next_frags.append(u1)
212            reg_prototile = reg_prototile.union(u1).difference(u2)
213          else:
214            u1 = u1.buffer(-tiling_utils.RESOLUTION, join_style = 2, cap_style = 3)
215            u2 = u2.buffer(-tiling_utils.RESOLUTION, join_style = 2, cap_style = 3)
216            next_frags.append(u2)
217            reg_prototile = reg_prototile.union(u2).difference(u1)
218          changes_made = True
219          frags_to_remove.add(i)
220          frags_to_remove.add(j)
221        fragments = [f for i, f in enumerate(fragments)
222                     if not (i in frags_to_remove)] + next_frags
223    self.regularised_prototile.geometry[0] = reg_prototile
224    return [f for f in fragments if not f.is_empty] # don't return any duds
225
226
227  def reattach_tiles(self) -> None:
228    """Move tiles that are outside the regularised prototile main polygon
229    back inside it adjusting regularised prototile if needed.
230    """
231    reg_prototile = self.regularised_prototile.geometry[0]
232    new_reg_prototile = copy.deepcopy(reg_prototile)
233    new_tiles = list(self.tiles.geometry)
234    for i, p in enumerate(self.tiles.geometry):
235      if np.isclose(reg_prototile.intersection(p).area, p.area):
236        new_tiles[i] = p
237        continue
238      for v in self.vectors.values():
239        t_p = affine.translate(p, v[0], v[1])
240        if reg_prototile.intersects(t_p):
241          new_reg_prototile = new_reg_prototile.union(t_p)
242          new_tiles[i] = t_p
243    self.tiles.geometry = gpd.GeoSeries(new_tiles)
244    self.regularised_prototile.geometry[0] = new_reg_prototile
245    return None
246
247
248  def regularise_tiles(self) -> None:
249    """Combines separate tiles that share a tile_id value into
250    single tiles, if they would end up touching after tiling.
251
252    Also adjusts the `Tileable.regularised_prototile`
253    attribute accordingly.
254    """
255    self.regularised_prototile = copy.deepcopy(self.prototile)
256    # This preserves order while finding uniques, unlike list(set()).
257    # Reordering ids might cause confusion when colour palettes
258    # are not assigned explicitly to each id, but in the order
259    # encountered in the tile_id Series of the GeoDataFrame.
260    tiles, tile_ids = [], []
261    ids = list(self.tiles.tile_id.unique())
262    for id in ids:
263      fragment_set = list(
264        self.tiles[self.tiles.tile_id == id].geometry)
265      merge_result = self.merge_fragments(fragment_set)
266      tiles.extend(merge_result)
267      tile_ids.extend([id] * len(merge_result))
268
269    self.tiles = gpd.GeoDataFrame(
270      data = {"tile_id": tile_ids},
271      crs = self.crs,
272      geometry = gpd.GeoSeries([tiling_utils.get_clean_polygon(t) 
273                                for t in tiles]))
274
275    self.regularised_prototile = \
276      self.regularised_prototile.explode(ignore_index = True)
277    if self.regularised_prototile.shape[0] > 1:
278      self.regularised_prototile.geometry = tiling_utils.get_largest_polygon(
279        self.regularised_prototile.geometry)
280    return None
281
282
283  def get_local_patch(self, r: int = 1,
284                      include_0: bool = False) -> gpd.GeoDataFrame:
285    """Returns a GeoDataFrame with translated copies of the Tileable.
286
287    The geodataframe takes the same form as the `Tileable.tile` attribute.
288
289    Args:
290      r (int, optional): the number of 'layers' out from the unit to
291        which the translate copies will extendt. Defaults to `1`.
292      include_0 (bool, optional): If True includes the Tileable itself at
293        (0, 0). Defaults to `False`.
294
295    Returns:
296      gpd.GeoDataFrame: A GeoDataframe of the tiles extended by a number 
297        of 'layers'.
298    """
299    # a dictionary of all the vectors we need, starting with (0, 0)
300    vecs = (
301      {(0, 0, 0): (0, 0)}
302      if self.base_shape in (TileShape.HEXAGON,)
303      else {(0, 0): (0, 0)}
304    )
305    steps = r if self.base_shape in (TileShape.HEXAGON,) else r * 2
306    # a dictionary of the last 'layer' of added vectors
307    last_vecs = copy.deepcopy(vecs)
308    # get the translation vectors in a dictionary indexed by coordinates
309    # we keep track of the sum of vectors using the (integer) coordinates
310    # to avoid duplication of moves due to floating point inaccuracies
311    vectors = self.get_vectors(as_dict = True)
312    for i in range(steps):
313      new_vecs = {}
314      for k1, v1 in last_vecs.items():
315        for k2, v2 in vectors.items():
316          # add the coordinates to make a new key...
317          new_key = tuple([k1[i] + k2[i] for i in range(len(k1))])
318          # ... and the vector components to make a new value
319          new_val = (v1[0] + v2[0], v1[1] + v2[1])
320          # if we haven't reached here before store it
321          if not new_key in vecs:
322            new_vecs[new_key] = new_val
323      # extend the vectors and set the last layer to the set just added
324      vecs = vecs | new_vecs
325      last_vecs = new_vecs
326    if not include_0:  # throw away the identity vector
327      vecs.pop((0, 0, 0) if self.base_shape in (TileShape.HEXAGON,) else (0, 0))
328    ids, tiles = [], []
329    # we need to add the translated prototiles in order of their distance from # tile 0, esp. in the square case, i.e. something like this:
330    #
331    #      5 4 3 4 5
332    #      4 2 1 2 4
333    #      3 1 0 1 3
334    #      4 2 1 2 4
335    #      5 4 3 4 5
336    #
337    # this is important for topology detection, where filtering back to the
338    # local patch of radius 1 is greatly eased if prototiles have been added in 
339    # this order. We use the vector index tuples not the euclidean distances
340    # because this may be more resistant to odd effects for non-convex tiles
341    extent = self.prototile.geometry.scale(
342      2 * r + tiling_utils.RESOLUTION, 2 * r + tiling_utils.RESOLUTION,
343      origin = self.prototile.geometry[0].centroid)[0]
344    vector_lengths = {index: np.sqrt(sum([_ ** 2 for _ in index]))
345                      for index in vecs.keys()}
346    ordered_vector_keys = [k for k, v in sorted(vector_lengths.items(), 
347                                                key = lambda item: item[1])]
348    for k in ordered_vector_keys:
349      v = vecs[k]
350      if geom.Point(v[0], v[1]).within(extent):
351        ids.extend(self.tiles.tile_id)
352        tiles.extend(
353          self.tiles.geometry.apply(affine.translate, xoff = v[0], yoff = v[1]))
354    return gpd.GeoDataFrame(
355      data = {"tile_id": ids}, crs=self.crs,
356      geometry = tiling_utils.gridify(gpd.GeoSeries(tiles))
357    )
358
359
360  def fit_tiles_to_prototile(self, centre_tile: int = 0) -> None:
361    """Fits the tiles so they sit inside the prototile boundary.
362
363    If tiles project outside the boundaries of the prototile, this
364    method will clip them so that they don't. This may result in
365    'fragmented' tiles, i.e. pieces that would form a single tile
366    after tiling which are separated into fragments.
367
368    Args:
369      centre_tile (int, optional): the index position of the central
370        tile. Defaults to `0`.
371    """
372    dxy = self.tiles.geometry[centre_tile].centroid
373    self.tiles.geometry = self.tiles.translate(-dxy.x, -dxy.y)
374    # use r = 2 because rectangular tiles may need diagonal neighbours
375    patch = (
376      self.get_local_patch(r=2, include_0=True)
377      if self.base_shape in (TileShape.RECTANGLE,)
378      else self.get_local_patch(r=1, include_0=True)
379    )
380    self.tiles = patch.clip(self.prototile)
381    # repair any weirdness...
382    self.tiles.geometry = tiling_utils.repair_polygon(self.tiles.geometry)
383    self.tiles = self.tiles[self.tiles.geometry.area > 0]
384    self.regularised_prototile = copy.deepcopy(self.prototile)
385    return None
386
387
388  # applicable to both TileUnits and WeaveUnits
389  def inset_tiles(self, inset: float = 0) -> "Tileable":
390    """Returns a new Tileable with an inset applied around the tiles.
391
392    Works by applying a negative buffer of specfied size to all tiles.
393    Tiles that collapse to zero area are removed and the tile_id
394    attribute updated accordingly.
395
396    NOTE: this method is likely to not preserve the relative area of tiles.
397
398    Args:
399      inset (float, optional): The distance to inset. Defaults to `0`.
400
401    Returns:
402      "Tileable": the new inset Tileable.
403    """
404    inset_tiles, inset_ids = [], []
405    for p, id in zip(self.tiles.geometry, self.tiles.tile_id):
406      b = p.buffer(-inset, join_style = 2, cap_style = 3)
407      if not b.area <= 0:
408        inset_tiles.append(b)
409        inset_ids.append(id)
410    result = copy.deepcopy(self)
411    result.tiles = gpd.GeoDataFrame(
412      data={"tile_id": inset_ids},
413      crs=self.crs,
414      geometry=gpd.GeoSeries(inset_tiles),
415    )
416    return result
417
418
419  def plot(
420    self,
421    ax=None,
422    show_prototile: bool = True,
423    show_reg_prototile: bool = True,
424    show_ids: str = "tile_id",
425    show_vectors: bool = False,
426    r: int = 0,
427    prototile_edgecolour: str = "k",
428    reg_prototile_edgecolour: str = "r",
429    r_alpha: float = 0.3,
430    cmap: list[str] = None,
431    figsize: tuple[float] = (8, 8),
432    **kwargs,
433  ) -> pyplot.axes:
434    """Plots a representation of the Tileable on the supplied axis. **kwargs
435    are passed on to matplotlib.plot()
436
437    Args:
438      ax (_type_, optional): matplotlib axis to draw to. Defaults to None.
439      show_prototile (bool, optional): if `True` show the tile outline.
440        Defaults to `True`.
441      show_reg_prototile (bool, optional): if `True` show the regularised tile
442        outline. Defaults to `True`.
443      show_ids (str, optional): if `tile_id` show the tile_ids. If
444        `id` show index number. If None or `""` don't label tiles.
445        Defaults to `tile_id`.
446      show_vectors (bool, optional): if `True` show the translation
447        vectors (not the minimal pair, but those used by
448        `get_local_patch()`). Defaults to `False`.
449      r (int, optional): passed to `get_local_patch()` to show context if
450        greater than 0. Defaults to `0`.
451      r_alpha (float, optional): alpha setting for units other than the
452        central one. Defaults to 0.3.
453      prototile_edgecolour (str, optional): outline colour for the tile.
454        Defaults to `"k"`.
455      reg_prototile_edgecolour (str, optional): outline colour for the
456        regularised. Defaults to `"r"`.
457      cmap (list[str], optional): colour map to apply to the central
458        tiles. Defaults to `None`.
459      figsize (tuple[float], optional): size of the figure.
460        Defaults to `(8, 8)`.
461    """
462    w = self.prototile.geometry[0].bounds[2] - \
463      self.prototile.geometry[0].bounds[0]
464    n_cols = len(set(self.tiles.tile_id))
465    if cmap is None:
466      cm = "Dark2" if n_cols <= 8 else "Paired"
467    else:
468      cm = cmap
469    if ax is None:
470      ax = self.tiles.plot(
471        column="tile_id", cmap=cm, figsize=figsize, **kwargs)
472    else:
473      self.tiles.plot(
474        ax=ax, column="tile_id", cmap=cm, figsize=figsize, **kwargs)
475    if show_ids != None and show_ids != "":
476      do_label = True
477      if show_ids == "tile_id" or show_ids == True:
478        labels = self.tiles.tile_id
479      elif show_ids == "id":
480        labels = [str(i) for i in range(self.tiles.shape[0])]
481      else:
482        do_label = False
483      if do_label:
484        for id, tile in zip(labels, self.tiles.geometry):
485          ax.annotate(id, (tile.centroid.x, tile.centroid.y),
486            ha = "center", va = "center", bbox = {"lw": 0, "fc": "#ffffff40"})
487    if r > 0:
488      self.get_local_patch(r=r).plot(
489        ax = ax, column = "tile_id", alpha = r_alpha, cmap = cm, **kwargs)
490    if show_prototile:
491      self.prototile.plot(ax = ax, ec = prototile_edgecolour, lw = 0.5, 
492                          fc = "#00000000", **kwargs)
493    if show_vectors:  # note that arrows in mpl are dimensioned in plotspace
494      vecs = self.get_vectors()
495      for v in vecs[: len(vecs) // 2]:
496        ax.arrow(0, 0, v[0], v[1], color = "k", width = w * 0.002,
497          head_width = w * 0.05, length_includes_head = True, zorder = 3)
498    if show_reg_prototile:
499      self.regularised_prototile.plot(
500        ax = ax, ec = reg_prototile_edgecolour, fc = "#00000000", 
501        lw = 1.5, zorder = 2, **kwargs)
502      # ax.annotate("""NOTE regularised prototile (red) indicative\nonly. Prototile and vectors (black) actually\ndo tiling. Regularised prototiles for weave\nunits are particularly problematic!""", 
503      #             xycoords = "axes fraction", xy = (0.01, 0.99), ha = "left", 
504      #             va = "top", bbox = {"lw": 0, "fc": "#ffffff40"})
505    return ax
506
507
508  def _get_legend_tiles(self):
509    """Returns the tiles augmented by a rotation column.
510
511    This base implementation may be overridden by specific tile unit types.
512    In particular see
513    `weavingspace.weave_unit.WeaveUnit._get_legend_tiles()`.
514    """
515    tiles = copy.deepcopy(self.tiles)
516    tiles["rotation"] = 0
517    return tiles
518
519
520  def transform_scale(self, xscale: float = 1.0, yscale: float = 1.0) -> "Tileable":
521    """Transforms tileable by scaling.
522
523    Args:
524      xscale (float, optional): x scale factor. Defaults to 1.0.
525      yscale (float, optional): y scale factor. Defaults to 1.0.
526
527    Returns:
528      Tileable: the transformed Tileable.
529    """
530    result = copy.deepcopy(self)
531    result.tiles.geometry = tiling_utils.gridify(
532      self.tiles.geometry.scale(xscale, yscale, origin=(0, 0)))
533    result.prototile.geometry = tiling_utils.gridify(
534      self.prototile.geometry.scale(xscale, yscale, origin=(0, 0)))
535    result.regularised_prototile.geometry = tiling_utils.gridify(
536      self.regularised_prototile.geometry.scale(xscale, yscale, origin=(0, 0)))
537    result.setup_vectors()
538    return result
539
540
541  def transform_rotate(self, angle: float = 0.0) -> "Tileable":
542    """Transforms tiling by rotation.
543
544    Args:
545      angle (float, optional): angle to rotate by. Defaults to 0.0.
546
547    Returns:
548      Tileable: the transformed Tileable.
549    """
550    result = copy.deepcopy(self)
551    result.tiles.geometry = tiling_utils.gridify(
552      self.tiles.geometry.rotate(angle, origin=(0, 0)))
553    result.prototile.geometry = tiling_utils.gridify(
554      self.prototile.geometry.rotate(angle, origin=(0, 0)))
555    result.regularised_prototile.geometry = tiling_utils.gridify(
556      self.regularised_prototile.geometry.rotate(angle, origin=(0, 0)))
557    result.setup_vectors()
558    result.rotation = result.rotation + angle
559    return result
560
561
562  def transform_skew(self, xa: float = 0.0, ya: float = 0.0) -> "Tileable":
563    """Transforms tiling by skewing
564
565    Args:
566      xa (float, optional): x direction skew. Defaults to 0.0.
567      ya (float, optional): y direction skew. Defaults to 0.0.
568
569    Returns:
570      Tileable: the transformed Tileable.
571    """
572    result = copy.deepcopy(self)
573    result.tiles.geometry = tiling_utils.gridify(
574      self.tiles.geometry.skew(xa, ya, origin=(0, 0)))
575    result.prototile.geometry = tiling_utils.gridify(
576      self.prototile.geometry.skew(xa, ya, origin=(0, 0)))
577    result.regularised_prototile.geometry = tiling_utils.gridify(
578      self.regularised_prototile.geometry.skew(xa, ya, origin=(0, 0)))
579    result.setup_vectors()
580    return result
class TileShape(enum.Enum):
32class TileShape(Enum):
33  """The available base tile shapes.
34
35  NOTE: the TRIANGLE type does not persist, but should be converted to a
36  DIAMOND or HEXAGON type during `Tileable` construction.
37  """
38
39  RECTANGLE = "rectangle"
40  HEXAGON = "hexagon"
41  TRIANGLE = "triangle"
42  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'>
Inherited Members
enum.Enum
name
value
@dataclass
class Tileable:
 45@dataclass
 46class Tileable:
 47  """Class to represent a tileable set of tile geometries.
 48  """
 49
 50  tiles: gpd.GeoDataFrame = None
 51  """the geometries with associated `title_id` attribute encoding their 
 52  different colouring."""
 53  prototile: gpd.GeoDataFrame = None
 54  """the tileable polygon (rectangle, hexagon or diamond)"""
 55  spacing: float = 1000.0
 56  """the tile spacing effectively the resolution of the tiling. Defaults to
 57  1000"""
 58  base_shape: TileShape = TileShape.RECTANGLE
 59  """the tile shape. Defaults to 'RECTANGLE'"""
 60  vectors: dict[tuple[int], tuple[float]] = None
 61  """translation vector symmetries of the tiling"""
 62  regularised_prototile: gpd.GeoDataFrame = None
 63  """polygon containing the tiles of this tileable, usually a union of its
 64  tile polygons"""
 65  crs: int = 3857
 66  """coordinate reference system of the tile. Most often an ESPG code but
 67  any valid geopandas CRS specification is valid. Defaults to 3857 (i.e. Web
 68  Mercator)."""
 69  rotation: float = 0.0
 70  """cumulative rotation of the tileable."""
 71  debug: bool = False
 72  """if True prints debug messages. Defaults to False."""
 73
 74  # Tileable constructor called by subclasses - should not be used directly
 75  def __init__(self, **kwargs):
 76    for k, v in kwargs.items():
 77      self.__dict__[k] = v
 78    if self.debug:
 79      print(
 80        f"""Debugging messages enabled for Tileable (but there aren't
 81          any at the moment...)"""
 82      )
 83    self._setup_tiles()
 84    self.setup_vectors()
 85    self._setup_regularised_prototile()
 86    return
 87
 88
 89  def setup_vectors(self) -> None:
 90    """Sets up the symmetry translation vectors as floating point pairs
 91    indexed by integer tuples with respect to either a rectangular or
 92    triangular grid location.
 93
 94    Derived from the size and shape of the tile attribute. These are not
 95    the minimal translation vectors, but the 'face to face' vectors of the
 96    tile, such that a hexagonal tile will have 3 vectors, not the minimal
 97    parallelogram pair. Also supplies the inverse vectors.
 98
 99    The vectors are stored in a dictionary indexed by their
100    coordinates, e.g.
101
102      {( 1,  0): ( 100, 0), ( 0,  1): (0,  100),
103       (-1,  0): (-100, 0), ( 0, -1): (0, -100)}
104
105    For a tileable of type `TileShape.HEXAGON`, the indexing tuples
106    have three components. See https://www.redblobgames.com/grids/hexagons/
107    """
108    t = self.prototile.geometry[0]
109    pts = [p for p in t.exterior.coords][:-1]
110    n_pts = len(pts)
111    vec_dict = {}
112    if n_pts == 4:
113      vecs = [(q[0] - p[0], q[1] - p[1])
114          for p, q in zip(pts, pts[1:] + pts[:1])]
115      i = [1, 0, -1,  0]
116      j = [0, 1,  0, -1]
117      vec_dict = {(i, j): v for i, j, v in zip(i, j, vecs)}
118    elif n_pts == 6:
119      vecs = [(q[0] - p[0], q[1] - p[1])
120          for p, q in zip(pts, pts[2:] + pts[:2])]
121      # hex grid coordinates associated with each of the vectors
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      vec_dict = {(i, j, k): v for i, j, k, v in zip(i, j, k, vecs)}
126    self.vectors = vec_dict
127
128
129  def get_vectors(
130      self, as_dict: bool = False
131    ) -> Union[dict[tuple[int], tuple[float]], list[tuple[float]]]:
132    """
133    Returns symmetry translation vectors as floating point pairs.
134    Optionally returns the vectors in a dictionary indexed by their
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      Union[ dict[tuple[int],tuple[float]], list[tuple[float]] ]:
142        either the vectors as a list of float tuples, or a dictionary
143        of those vectors indexed by integer coordinate tuples.
144    """
145    if as_dict:
146      return self.vectors
147    else:
148      return list(self.vectors.values())
149
150
151  # Make up a regularised tile by carefully unioning the tiles
152  def setup_regularised_prototile_from_tiles(self) -> None:
153    """Sets the regularised tile to a union of the tiles."""
154    self.regularised_prototile = copy.deepcopy(self.prototile)
155    self.regularised_prototile.geometry = [tiling_utils.safe_union(
156      self.tiles.geometry, as_polygon = True)]
157    # This simplification seems very crude but fixes all kinds of issues...
158    # particularly with the triaxial weave units... where intersection 
159    # operations are prone to creating spurious vertices, etc.
160    # self.regularised_prototile.geometry[0] = \
161    #   self.regularised_prototile.geometry[0].simplify(
162    #     self.spacing * tiling_utils.RESOLUTION)
163    return
164
165
166  def merge_fragments(self, fragments:list[geom.Polygon]) -> list[geom.Polygon]:
167    """
168    Merges a set of polygons based on testing if they touch when subjected
169    to the translation vectors provided by `get_vectors()`.
170
171    Called by `regularise_tiles()` to combine tiles in a tile unit that
172    may be fragmented as supplied but will combine after tiling into single
173    tiles. This step makes for more efficient implementation of the
174    tiling of map regions.
175
176    Args:
177      fragments (list[geom.Polygon]): A set of polygons to merge.
178
179    Returns:
180      list[geom.Polygon]: A minimal list of merged polygons.
181    """
182    if len(fragments) == 1:
183      return [f for f in fragments if not f.is_empty]
184    fragments = [f for f in fragments if not f.is_empty]
185    prototile = self.prototile.geometry[0]
186    reg_prototile = copy.deepcopy(self.regularised_prototile.geometry[0])
187    changes_made = True
188    while changes_made:
189      changes_made = False
190      for v in self.vectors.values():
191        # empty list to collect the new fragments
192        # assembled in this iteration
193        next_frags = []
194        t_frags = [affine.translate(f, v[0], v[1]) for f in fragments]
195        # build a set of any near matching pairs of
196        # fragments and their translated copies
197        matches = set()
198        for i, f1 in enumerate(fragments):
199          for j, f2, in enumerate(t_frags):
200            if i < j and tiling_utils.touch_along_an_edge(f1, f2):
201              matches.add((i, j))
202        # determine which of these when unioned has the larger area in common # with the prototile
203        frags_to_remove = set()
204        for i, j in matches:
205          f1, f2 = fragments[i], t_frags[j]
206          u1 = f1.buffer(tiling_utils.RESOLUTION, join_style = 2, cap_style = 3).union(
207            f2.buffer(tiling_utils.RESOLUTION, join_style = 2, cap_style = 3))
208          u2 = affine.translate(u1, -v[0], -v[1])
209          if prototile.intersection(u1).area > prototile.intersection(u2).area:
210            u1 = u1.buffer(-tiling_utils.RESOLUTION, join_style = 2, cap_style = 3)
211            u2 = u2.buffer(-tiling_utils.RESOLUTION, join_style = 2, cap_style = 3)
212            next_frags.append(u1)
213            reg_prototile = reg_prototile.union(u1).difference(u2)
214          else:
215            u1 = u1.buffer(-tiling_utils.RESOLUTION, join_style = 2, cap_style = 3)
216            u2 = u2.buffer(-tiling_utils.RESOLUTION, join_style = 2, cap_style = 3)
217            next_frags.append(u2)
218            reg_prototile = reg_prototile.union(u2).difference(u1)
219          changes_made = True
220          frags_to_remove.add(i)
221          frags_to_remove.add(j)
222        fragments = [f for i, f in enumerate(fragments)
223                     if not (i in frags_to_remove)] + next_frags
224    self.regularised_prototile.geometry[0] = reg_prototile
225    return [f for f in fragments if not f.is_empty] # don't return any duds
226
227
228  def reattach_tiles(self) -> None:
229    """Move tiles that are outside the regularised prototile main polygon
230    back inside it adjusting regularised prototile if needed.
231    """
232    reg_prototile = self.regularised_prototile.geometry[0]
233    new_reg_prototile = copy.deepcopy(reg_prototile)
234    new_tiles = list(self.tiles.geometry)
235    for i, p in enumerate(self.tiles.geometry):
236      if np.isclose(reg_prototile.intersection(p).area, p.area):
237        new_tiles[i] = p
238        continue
239      for v in self.vectors.values():
240        t_p = affine.translate(p, v[0], v[1])
241        if reg_prototile.intersects(t_p):
242          new_reg_prototile = new_reg_prototile.union(t_p)
243          new_tiles[i] = t_p
244    self.tiles.geometry = gpd.GeoSeries(new_tiles)
245    self.regularised_prototile.geometry[0] = new_reg_prototile
246    return None
247
248
249  def regularise_tiles(self) -> None:
250    """Combines separate tiles that share a tile_id value into
251    single tiles, if they would end up touching after tiling.
252
253    Also adjusts the `Tileable.regularised_prototile`
254    attribute accordingly.
255    """
256    self.regularised_prototile = copy.deepcopy(self.prototile)
257    # This preserves order while finding uniques, unlike list(set()).
258    # Reordering ids might cause confusion when colour palettes
259    # are not assigned explicitly to each id, but in the order
260    # encountered in the tile_id Series of the GeoDataFrame.
261    tiles, tile_ids = [], []
262    ids = list(self.tiles.tile_id.unique())
263    for id in ids:
264      fragment_set = list(
265        self.tiles[self.tiles.tile_id == id].geometry)
266      merge_result = self.merge_fragments(fragment_set)
267      tiles.extend(merge_result)
268      tile_ids.extend([id] * len(merge_result))
269
270    self.tiles = gpd.GeoDataFrame(
271      data = {"tile_id": tile_ids},
272      crs = self.crs,
273      geometry = gpd.GeoSeries([tiling_utils.get_clean_polygon(t) 
274                                for t in tiles]))
275
276    self.regularised_prototile = \
277      self.regularised_prototile.explode(ignore_index = True)
278    if self.regularised_prototile.shape[0] > 1:
279      self.regularised_prototile.geometry = tiling_utils.get_largest_polygon(
280        self.regularised_prototile.geometry)
281    return None
282
283
284  def get_local_patch(self, r: int = 1,
285                      include_0: bool = False) -> gpd.GeoDataFrame:
286    """Returns a GeoDataFrame with translated copies of the Tileable.
287
288    The geodataframe takes the same form as the `Tileable.tile` attribute.
289
290    Args:
291      r (int, optional): the number of 'layers' out from the unit to
292        which the translate copies will extendt. Defaults to `1`.
293      include_0 (bool, optional): If True includes the Tileable itself at
294        (0, 0). Defaults to `False`.
295
296    Returns:
297      gpd.GeoDataFrame: A GeoDataframe of the tiles extended by a number 
298        of 'layers'.
299    """
300    # a dictionary of all the vectors we need, starting with (0, 0)
301    vecs = (
302      {(0, 0, 0): (0, 0)}
303      if self.base_shape in (TileShape.HEXAGON,)
304      else {(0, 0): (0, 0)}
305    )
306    steps = r if self.base_shape in (TileShape.HEXAGON,) else r * 2
307    # a dictionary of the last 'layer' of added vectors
308    last_vecs = copy.deepcopy(vecs)
309    # get the translation vectors in a dictionary indexed by coordinates
310    # we keep track of the sum of vectors using the (integer) coordinates
311    # to avoid duplication of moves due to floating point inaccuracies
312    vectors = self.get_vectors(as_dict = True)
313    for i in range(steps):
314      new_vecs = {}
315      for k1, v1 in last_vecs.items():
316        for k2, v2 in vectors.items():
317          # add the coordinates to make a new key...
318          new_key = tuple([k1[i] + k2[i] for i in range(len(k1))])
319          # ... and the vector components to make a new value
320          new_val = (v1[0] + v2[0], v1[1] + v2[1])
321          # if we haven't reached here before store it
322          if not new_key in vecs:
323            new_vecs[new_key] = new_val
324      # extend the vectors and set the last layer to the set just added
325      vecs = vecs | new_vecs
326      last_vecs = new_vecs
327    if not include_0:  # throw away the identity vector
328      vecs.pop((0, 0, 0) if self.base_shape in (TileShape.HEXAGON,) else (0, 0))
329    ids, tiles = [], []
330    # we need to add the translated prototiles in order of their distance from # tile 0, esp. in the square case, i.e. something like this:
331    #
332    #      5 4 3 4 5
333    #      4 2 1 2 4
334    #      3 1 0 1 3
335    #      4 2 1 2 4
336    #      5 4 3 4 5
337    #
338    # this is important for topology detection, where filtering back to the
339    # local patch of radius 1 is greatly eased if prototiles have been added in 
340    # this order. We use the vector index tuples not the euclidean distances
341    # because this may be more resistant to odd effects for non-convex tiles
342    extent = self.prototile.geometry.scale(
343      2 * r + tiling_utils.RESOLUTION, 2 * r + tiling_utils.RESOLUTION,
344      origin = self.prototile.geometry[0].centroid)[0]
345    vector_lengths = {index: np.sqrt(sum([_ ** 2 for _ in index]))
346                      for index in vecs.keys()}
347    ordered_vector_keys = [k for k, v in sorted(vector_lengths.items(), 
348                                                key = lambda item: item[1])]
349    for k in ordered_vector_keys:
350      v = vecs[k]
351      if geom.Point(v[0], v[1]).within(extent):
352        ids.extend(self.tiles.tile_id)
353        tiles.extend(
354          self.tiles.geometry.apply(affine.translate, xoff = v[0], yoff = v[1]))
355    return gpd.GeoDataFrame(
356      data = {"tile_id": ids}, crs=self.crs,
357      geometry = tiling_utils.gridify(gpd.GeoSeries(tiles))
358    )
359
360
361  def fit_tiles_to_prototile(self, centre_tile: int = 0) -> None:
362    """Fits the tiles so they sit inside the prototile boundary.
363
364    If tiles project outside the boundaries of the prototile, this
365    method will clip them so that they don't. This may result in
366    'fragmented' tiles, i.e. pieces that would form a single tile
367    after tiling which are separated into fragments.
368
369    Args:
370      centre_tile (int, optional): the index position of the central
371        tile. Defaults to `0`.
372    """
373    dxy = self.tiles.geometry[centre_tile].centroid
374    self.tiles.geometry = self.tiles.translate(-dxy.x, -dxy.y)
375    # use r = 2 because rectangular tiles may need diagonal neighbours
376    patch = (
377      self.get_local_patch(r=2, include_0=True)
378      if self.base_shape in (TileShape.RECTANGLE,)
379      else self.get_local_patch(r=1, include_0=True)
380    )
381    self.tiles = patch.clip(self.prototile)
382    # repair any weirdness...
383    self.tiles.geometry = tiling_utils.repair_polygon(self.tiles.geometry)
384    self.tiles = self.tiles[self.tiles.geometry.area > 0]
385    self.regularised_prototile = copy.deepcopy(self.prototile)
386    return None
387
388
389  # applicable to both TileUnits and WeaveUnits
390  def inset_tiles(self, inset: float = 0) -> "Tileable":
391    """Returns a new Tileable with an inset applied around the tiles.
392
393    Works by applying a negative buffer of specfied size to all tiles.
394    Tiles that collapse to zero area are removed and the tile_id
395    attribute updated accordingly.
396
397    NOTE: this method is likely to not preserve the relative area of tiles.
398
399    Args:
400      inset (float, optional): The distance to inset. Defaults to `0`.
401
402    Returns:
403      "Tileable": the new inset Tileable.
404    """
405    inset_tiles, inset_ids = [], []
406    for p, id in zip(self.tiles.geometry, self.tiles.tile_id):
407      b = p.buffer(-inset, join_style = 2, cap_style = 3)
408      if not b.area <= 0:
409        inset_tiles.append(b)
410        inset_ids.append(id)
411    result = copy.deepcopy(self)
412    result.tiles = gpd.GeoDataFrame(
413      data={"tile_id": inset_ids},
414      crs=self.crs,
415      geometry=gpd.GeoSeries(inset_tiles),
416    )
417    return result
418
419
420  def plot(
421    self,
422    ax=None,
423    show_prototile: bool = True,
424    show_reg_prototile: bool = True,
425    show_ids: str = "tile_id",
426    show_vectors: bool = False,
427    r: int = 0,
428    prototile_edgecolour: str = "k",
429    reg_prototile_edgecolour: str = "r",
430    r_alpha: float = 0.3,
431    cmap: list[str] = None,
432    figsize: tuple[float] = (8, 8),
433    **kwargs,
434  ) -> pyplot.axes:
435    """Plots a representation of the Tileable on the supplied axis. **kwargs
436    are passed on to matplotlib.plot()
437
438    Args:
439      ax (_type_, optional): matplotlib axis to draw to. Defaults to None.
440      show_prototile (bool, optional): if `True` show the tile outline.
441        Defaults to `True`.
442      show_reg_prototile (bool, optional): if `True` show the regularised tile
443        outline. Defaults to `True`.
444      show_ids (str, optional): if `tile_id` show the tile_ids. If
445        `id` show index number. If None or `""` don't label tiles.
446        Defaults to `tile_id`.
447      show_vectors (bool, optional): if `True` show the translation
448        vectors (not the minimal pair, but those used by
449        `get_local_patch()`). Defaults to `False`.
450      r (int, optional): passed to `get_local_patch()` to show context if
451        greater than 0. Defaults to `0`.
452      r_alpha (float, optional): alpha setting for units other than the
453        central one. Defaults to 0.3.
454      prototile_edgecolour (str, optional): outline colour for the tile.
455        Defaults to `"k"`.
456      reg_prototile_edgecolour (str, optional): outline colour for the
457        regularised. Defaults to `"r"`.
458      cmap (list[str], optional): colour map to apply to the central
459        tiles. Defaults to `None`.
460      figsize (tuple[float], optional): size of the figure.
461        Defaults to `(8, 8)`.
462    """
463    w = self.prototile.geometry[0].bounds[2] - \
464      self.prototile.geometry[0].bounds[0]
465    n_cols = len(set(self.tiles.tile_id))
466    if cmap is None:
467      cm = "Dark2" if n_cols <= 8 else "Paired"
468    else:
469      cm = cmap
470    if ax is None:
471      ax = self.tiles.plot(
472        column="tile_id", cmap=cm, figsize=figsize, **kwargs)
473    else:
474      self.tiles.plot(
475        ax=ax, column="tile_id", cmap=cm, figsize=figsize, **kwargs)
476    if show_ids != None and show_ids != "":
477      do_label = True
478      if show_ids == "tile_id" or show_ids == True:
479        labels = self.tiles.tile_id
480      elif show_ids == "id":
481        labels = [str(i) for i in range(self.tiles.shape[0])]
482      else:
483        do_label = False
484      if do_label:
485        for id, tile in zip(labels, self.tiles.geometry):
486          ax.annotate(id, (tile.centroid.x, tile.centroid.y),
487            ha = "center", va = "center", bbox = {"lw": 0, "fc": "#ffffff40"})
488    if r > 0:
489      self.get_local_patch(r=r).plot(
490        ax = ax, column = "tile_id", alpha = r_alpha, cmap = cm, **kwargs)
491    if show_prototile:
492      self.prototile.plot(ax = ax, ec = prototile_edgecolour, lw = 0.5, 
493                          fc = "#00000000", **kwargs)
494    if show_vectors:  # note that arrows in mpl are dimensioned in plotspace
495      vecs = self.get_vectors()
496      for v in vecs[: len(vecs) // 2]:
497        ax.arrow(0, 0, v[0], v[1], color = "k", width = w * 0.002,
498          head_width = w * 0.05, length_includes_head = True, zorder = 3)
499    if show_reg_prototile:
500      self.regularised_prototile.plot(
501        ax = ax, ec = reg_prototile_edgecolour, fc = "#00000000", 
502        lw = 1.5, zorder = 2, **kwargs)
503      # ax.annotate("""NOTE regularised prototile (red) indicative\nonly. Prototile and vectors (black) actually\ndo tiling. Regularised prototiles for weave\nunits are particularly problematic!""", 
504      #             xycoords = "axes fraction", xy = (0.01, 0.99), ha = "left", 
505      #             va = "top", bbox = {"lw": 0, "fc": "#ffffff40"})
506    return ax
507
508
509  def _get_legend_tiles(self):
510    """Returns the tiles augmented by a rotation column.
511
512    This base implementation may be overridden by specific tile unit types.
513    In particular see
514    `weavingspace.weave_unit.WeaveUnit._get_legend_tiles()`.
515    """
516    tiles = copy.deepcopy(self.tiles)
517    tiles["rotation"] = 0
518    return tiles
519
520
521  def transform_scale(self, xscale: float = 1.0, yscale: float = 1.0) -> "Tileable":
522    """Transforms tileable by scaling.
523
524    Args:
525      xscale (float, optional): x scale factor. Defaults to 1.0.
526      yscale (float, optional): y scale factor. Defaults to 1.0.
527
528    Returns:
529      Tileable: the transformed Tileable.
530    """
531    result = copy.deepcopy(self)
532    result.tiles.geometry = tiling_utils.gridify(
533      self.tiles.geometry.scale(xscale, yscale, origin=(0, 0)))
534    result.prototile.geometry = tiling_utils.gridify(
535      self.prototile.geometry.scale(xscale, yscale, origin=(0, 0)))
536    result.regularised_prototile.geometry = tiling_utils.gridify(
537      self.regularised_prototile.geometry.scale(xscale, yscale, origin=(0, 0)))
538    result.setup_vectors()
539    return result
540
541
542  def transform_rotate(self, angle: float = 0.0) -> "Tileable":
543    """Transforms tiling by rotation.
544
545    Args:
546      angle (float, optional): angle to rotate by. Defaults to 0.0.
547
548    Returns:
549      Tileable: the transformed Tileable.
550    """
551    result = copy.deepcopy(self)
552    result.tiles.geometry = tiling_utils.gridify(
553      self.tiles.geometry.rotate(angle, origin=(0, 0)))
554    result.prototile.geometry = tiling_utils.gridify(
555      self.prototile.geometry.rotate(angle, origin=(0, 0)))
556    result.regularised_prototile.geometry = tiling_utils.gridify(
557      self.regularised_prototile.geometry.rotate(angle, origin=(0, 0)))
558    result.setup_vectors()
559    result.rotation = result.rotation + angle
560    return result
561
562
563  def transform_skew(self, xa: float = 0.0, ya: float = 0.0) -> "Tileable":
564    """Transforms tiling by skewing
565
566    Args:
567      xa (float, optional): x direction skew. Defaults to 0.0.
568      ya (float, optional): y direction skew. Defaults to 0.0.
569
570    Returns:
571      Tileable: the transformed Tileable.
572    """
573    result = copy.deepcopy(self)
574    result.tiles.geometry = tiling_utils.gridify(
575      self.tiles.geometry.skew(xa, ya, origin=(0, 0)))
576    result.prototile.geometry = tiling_utils.gridify(
577      self.prototile.geometry.skew(xa, ya, origin=(0, 0)))
578    result.regularised_prototile.geometry = tiling_utils.gridify(
579      self.regularised_prototile.geometry.skew(xa, ya, origin=(0, 0)))
580    result.setup_vectors()
581    return result

Class to represent a tileable set of tile geometries.

Tileable(**kwargs)
75  def __init__(self, **kwargs):
76    for k, v in kwargs.items():
77      self.__dict__[k] = v
78    if self.debug:
79      print(
80        f"""Debugging messages enabled for Tileable (but there aren't
81          any at the moment...)"""
82      )
83    self._setup_tiles()
84    self.setup_vectors()
85    self._setup_regularised_prototile()
86    return
tiles: geopandas.geodataframe.GeoDataFrame = None

the geometries with associated title_id attribute encoding their different colouring.

prototile: geopandas.geodataframe.GeoDataFrame = 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

translation vector symmetries of the tiling

regularised_prototile: geopandas.geodataframe.GeoDataFrame = 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) -> None:
 89  def setup_vectors(self) -> None:
 90    """Sets up the symmetry translation vectors as floating point pairs
 91    indexed by integer tuples with respect to either a rectangular or
 92    triangular grid location.
 93
 94    Derived from the size and shape of the tile attribute. These are not
 95    the minimal translation vectors, but the 'face to face' vectors of the
 96    tile, such that a hexagonal tile will have 3 vectors, not the minimal
 97    parallelogram pair. Also supplies the inverse vectors.
 98
 99    The vectors are stored in a dictionary indexed by their
100    coordinates, e.g.
101
102      {( 1,  0): ( 100, 0), ( 0,  1): (0,  100),
103       (-1,  0): (-100, 0), ( 0, -1): (0, -100)}
104
105    For a tileable of type `TileShape.HEXAGON`, the indexing tuples
106    have three components. See https://www.redblobgames.com/grids/hexagons/
107    """
108    t = self.prototile.geometry[0]
109    pts = [p for p in t.exterior.coords][:-1]
110    n_pts = len(pts)
111    vec_dict = {}
112    if n_pts == 4:
113      vecs = [(q[0] - p[0], q[1] - p[1])
114          for p, q in zip(pts, pts[1:] + pts[:1])]
115      i = [1, 0, -1,  0]
116      j = [0, 1,  0, -1]
117      vec_dict = {(i, j): v for i, j, v in zip(i, j, vecs)}
118    elif n_pts == 6:
119      vecs = [(q[0] - p[0], q[1] - p[1])
120          for p, q in zip(pts, pts[2:] + pts[:2])]
121      # hex grid coordinates associated with each of the vectors
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      vec_dict = {(i, j, k): v for i, j, k, v in zip(i, j, k, vecs)}
126    self.vectors = vec_dict

Sets up the symmetry translation vectors as floating point pairs indexed by integer tuples with respect to either a rectangular or triangular grid location.

Derived from the size and shape of the tile attribute. These are not the minimal translation vectors, but the 'face to face' vectors of the tile, such that a hexagonal tile will have 3 vectors, not the minimal parallelogram pair. Also supplies the inverse vectors.

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) -> Union[dict[tuple[int], tuple[float]], list[tuple[float]]]:
129  def get_vectors(
130      self, as_dict: bool = False
131    ) -> Union[dict[tuple[int], tuple[float]], list[tuple[float]]]:
132    """
133    Returns symmetry translation vectors as floating point pairs.
134    Optionally returns the vectors in a dictionary indexed by their
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      Union[ dict[tuple[int],tuple[float]], list[tuple[float]] ]:
142        either the vectors as a list of float tuples, or a dictionary
143        of those vectors indexed by integer coordinate tuples.
144    """
145    if as_dict:
146      return self.vectors
147    else:
148      return list(self.vectors.values())

Returns symmetry translation vectors as floating point pairs. Optionally returns the vectors 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)}

Returns:

Union[ 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 setup_regularised_prototile_from_tiles(self) -> None:
152  def setup_regularised_prototile_from_tiles(self) -> None:
153    """Sets the regularised tile to a union of the tiles."""
154    self.regularised_prototile = copy.deepcopy(self.prototile)
155    self.regularised_prototile.geometry = [tiling_utils.safe_union(
156      self.tiles.geometry, as_polygon = True)]
157    # This simplification seems very crude but fixes all kinds of issues...
158    # particularly with the triaxial weave units... where intersection 
159    # operations are prone to creating spurious vertices, etc.
160    # self.regularised_prototile.geometry[0] = \
161    #   self.regularised_prototile.geometry[0].simplify(
162    #     self.spacing * tiling_utils.RESOLUTION)
163    return

Sets the regularised tile to a union of the tiles.

def merge_fragments( self, fragments: list[shapely.geometry.polygon.Polygon]) -> list[shapely.geometry.polygon.Polygon]:
166  def merge_fragments(self, fragments:list[geom.Polygon]) -> list[geom.Polygon]:
167    """
168    Merges a set of polygons based on testing if they touch when subjected
169    to the translation vectors provided by `get_vectors()`.
170
171    Called by `regularise_tiles()` to combine tiles in a tile unit that
172    may be fragmented as supplied but will combine after tiling into single
173    tiles. This step makes for more efficient implementation of the
174    tiling of map regions.
175
176    Args:
177      fragments (list[geom.Polygon]): A set of polygons to merge.
178
179    Returns:
180      list[geom.Polygon]: A minimal list of merged polygons.
181    """
182    if len(fragments) == 1:
183      return [f for f in fragments if not f.is_empty]
184    fragments = [f for f in fragments if not f.is_empty]
185    prototile = self.prototile.geometry[0]
186    reg_prototile = copy.deepcopy(self.regularised_prototile.geometry[0])
187    changes_made = True
188    while changes_made:
189      changes_made = False
190      for v in self.vectors.values():
191        # empty list to collect the new fragments
192        # assembled in this iteration
193        next_frags = []
194        t_frags = [affine.translate(f, v[0], v[1]) for f in fragments]
195        # build a set of any near matching pairs of
196        # fragments and their translated copies
197        matches = set()
198        for i, f1 in enumerate(fragments):
199          for j, f2, in enumerate(t_frags):
200            if i < j and tiling_utils.touch_along_an_edge(f1, f2):
201              matches.add((i, j))
202        # determine which of these when unioned has the larger area in common # with the prototile
203        frags_to_remove = set()
204        for i, j in matches:
205          f1, f2 = fragments[i], t_frags[j]
206          u1 = f1.buffer(tiling_utils.RESOLUTION, join_style = 2, cap_style = 3).union(
207            f2.buffer(tiling_utils.RESOLUTION, join_style = 2, cap_style = 3))
208          u2 = affine.translate(u1, -v[0], -v[1])
209          if prototile.intersection(u1).area > prototile.intersection(u2).area:
210            u1 = u1.buffer(-tiling_utils.RESOLUTION, join_style = 2, cap_style = 3)
211            u2 = u2.buffer(-tiling_utils.RESOLUTION, join_style = 2, cap_style = 3)
212            next_frags.append(u1)
213            reg_prototile = reg_prototile.union(u1).difference(u2)
214          else:
215            u1 = u1.buffer(-tiling_utils.RESOLUTION, join_style = 2, cap_style = 3)
216            u2 = u2.buffer(-tiling_utils.RESOLUTION, join_style = 2, cap_style = 3)
217            next_frags.append(u2)
218            reg_prototile = reg_prototile.union(u2).difference(u1)
219          changes_made = True
220          frags_to_remove.add(i)
221          frags_to_remove.add(j)
222        fragments = [f for i, f in enumerate(fragments)
223                     if not (i in frags_to_remove)] + next_frags
224    self.regularised_prototile.geometry[0] = reg_prototile
225    return [f for f in fragments if not f.is_empty] # don't return any duds

Merges a set of polygons based on testing if they touch when subjected to the translation vectors provided by get_vectors().

Called by regularise_tiles() to combine tiles in a tile unit that may be fragmented as supplied but will combine after tiling into single tiles. This step makes for more efficient implementation of the tiling of map regions.

Arguments:
  • fragments (list[geom.Polygon]): A set of polygons to merge.
Returns:

list[geom.Polygon]: A minimal list of merged polygons.

def reattach_tiles(self) -> None:
228  def reattach_tiles(self) -> None:
229    """Move tiles that are outside the regularised prototile main polygon
230    back inside it adjusting regularised prototile if needed.
231    """
232    reg_prototile = self.regularised_prototile.geometry[0]
233    new_reg_prototile = copy.deepcopy(reg_prototile)
234    new_tiles = list(self.tiles.geometry)
235    for i, p in enumerate(self.tiles.geometry):
236      if np.isclose(reg_prototile.intersection(p).area, p.area):
237        new_tiles[i] = p
238        continue
239      for v in self.vectors.values():
240        t_p = affine.translate(p, v[0], v[1])
241        if reg_prototile.intersects(t_p):
242          new_reg_prototile = new_reg_prototile.union(t_p)
243          new_tiles[i] = t_p
244    self.tiles.geometry = gpd.GeoSeries(new_tiles)
245    self.regularised_prototile.geometry[0] = new_reg_prototile
246    return None

Move tiles that are outside the regularised prototile main polygon back inside it adjusting regularised prototile if needed.

def regularise_tiles(self) -> None:
249  def regularise_tiles(self) -> None:
250    """Combines separate tiles that share a tile_id value into
251    single tiles, if they would end up touching after tiling.
252
253    Also adjusts the `Tileable.regularised_prototile`
254    attribute accordingly.
255    """
256    self.regularised_prototile = copy.deepcopy(self.prototile)
257    # This preserves order while finding uniques, unlike list(set()).
258    # Reordering ids might cause confusion when colour palettes
259    # are not assigned explicitly to each id, but in the order
260    # encountered in the tile_id Series of the GeoDataFrame.
261    tiles, tile_ids = [], []
262    ids = list(self.tiles.tile_id.unique())
263    for id in ids:
264      fragment_set = list(
265        self.tiles[self.tiles.tile_id == id].geometry)
266      merge_result = self.merge_fragments(fragment_set)
267      tiles.extend(merge_result)
268      tile_ids.extend([id] * len(merge_result))
269
270    self.tiles = gpd.GeoDataFrame(
271      data = {"tile_id": tile_ids},
272      crs = self.crs,
273      geometry = gpd.GeoSeries([tiling_utils.get_clean_polygon(t) 
274                                for t in tiles]))
275
276    self.regularised_prototile = \
277      self.regularised_prototile.explode(ignore_index = True)
278    if self.regularised_prototile.shape[0] > 1:
279      self.regularised_prototile.geometry = tiling_utils.get_largest_polygon(
280        self.regularised_prototile.geometry)
281    return None

Combines separate tiles that share a tile_id value into single tiles, if they would end up touching after tiling.

Also adjusts the Tileable.regularised_prototile attribute accordingly.

def get_local_patch( self, r: int = 1, include_0: bool = False) -> geopandas.geodataframe.GeoDataFrame:
284  def get_local_patch(self, r: int = 1,
285                      include_0: bool = False) -> gpd.GeoDataFrame:
286    """Returns a GeoDataFrame with translated copies of the Tileable.
287
288    The geodataframe takes the same form as the `Tileable.tile` attribute.
289
290    Args:
291      r (int, optional): the number of 'layers' out from the unit to
292        which the translate copies will extendt. Defaults to `1`.
293      include_0 (bool, optional): If True includes the Tileable itself at
294        (0, 0). Defaults to `False`.
295
296    Returns:
297      gpd.GeoDataFrame: A GeoDataframe of the tiles extended by a number 
298        of 'layers'.
299    """
300    # a dictionary of all the vectors we need, starting with (0, 0)
301    vecs = (
302      {(0, 0, 0): (0, 0)}
303      if self.base_shape in (TileShape.HEXAGON,)
304      else {(0, 0): (0, 0)}
305    )
306    steps = r if self.base_shape in (TileShape.HEXAGON,) else r * 2
307    # a dictionary of the last 'layer' of added vectors
308    last_vecs = copy.deepcopy(vecs)
309    # get the translation vectors in a dictionary indexed by coordinates
310    # we keep track of the sum of vectors using the (integer) coordinates
311    # to avoid duplication of moves due to floating point inaccuracies
312    vectors = self.get_vectors(as_dict = True)
313    for i in range(steps):
314      new_vecs = {}
315      for k1, v1 in last_vecs.items():
316        for k2, v2 in vectors.items():
317          # add the coordinates to make a new key...
318          new_key = tuple([k1[i] + k2[i] for i in range(len(k1))])
319          # ... and the vector components to make a new value
320          new_val = (v1[0] + v2[0], v1[1] + v2[1])
321          # if we haven't reached here before store it
322          if not new_key in vecs:
323            new_vecs[new_key] = new_val
324      # extend the vectors and set the last layer to the set just added
325      vecs = vecs | new_vecs
326      last_vecs = new_vecs
327    if not include_0:  # throw away the identity vector
328      vecs.pop((0, 0, 0) if self.base_shape in (TileShape.HEXAGON,) else (0, 0))
329    ids, tiles = [], []
330    # we need to add the translated prototiles in order of their distance from # tile 0, esp. in the square case, i.e. something like this:
331    #
332    #      5 4 3 4 5
333    #      4 2 1 2 4
334    #      3 1 0 1 3
335    #      4 2 1 2 4
336    #      5 4 3 4 5
337    #
338    # this is important for topology detection, where filtering back to the
339    # local patch of radius 1 is greatly eased if prototiles have been added in 
340    # this order. We use the vector index tuples not the euclidean distances
341    # because this may be more resistant to odd effects for non-convex tiles
342    extent = self.prototile.geometry.scale(
343      2 * r + tiling_utils.RESOLUTION, 2 * r + tiling_utils.RESOLUTION,
344      origin = self.prototile.geometry[0].centroid)[0]
345    vector_lengths = {index: np.sqrt(sum([_ ** 2 for _ in index]))
346                      for index in vecs.keys()}
347    ordered_vector_keys = [k for k, v in sorted(vector_lengths.items(), 
348                                                key = lambda item: item[1])]
349    for k in ordered_vector_keys:
350      v = vecs[k]
351      if geom.Point(v[0], v[1]).within(extent):
352        ids.extend(self.tiles.tile_id)
353        tiles.extend(
354          self.tiles.geometry.apply(affine.translate, xoff = v[0], yoff = v[1]))
355    return gpd.GeoDataFrame(
356      data = {"tile_id": ids}, crs=self.crs,
357      geometry = tiling_utils.gridify(gpd.GeoSeries(tiles))
358    )

Returns 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 fit_tiles_to_prototile(self, centre_tile: int = 0) -> None:
361  def fit_tiles_to_prototile(self, centre_tile: int = 0) -> None:
362    """Fits the tiles so they sit inside the prototile boundary.
363
364    If tiles project outside the boundaries of the prototile, this
365    method will clip them so that they don't. This may result in
366    'fragmented' tiles, i.e. pieces that would form a single tile
367    after tiling which are separated into fragments.
368
369    Args:
370      centre_tile (int, optional): the index position of the central
371        tile. Defaults to `0`.
372    """
373    dxy = self.tiles.geometry[centre_tile].centroid
374    self.tiles.geometry = self.tiles.translate(-dxy.x, -dxy.y)
375    # use r = 2 because rectangular tiles may need diagonal neighbours
376    patch = (
377      self.get_local_patch(r=2, include_0=True)
378      if self.base_shape in (TileShape.RECTANGLE,)
379      else self.get_local_patch(r=1, include_0=True)
380    )
381    self.tiles = patch.clip(self.prototile)
382    # repair any weirdness...
383    self.tiles.geometry = tiling_utils.repair_polygon(self.tiles.geometry)
384    self.tiles = self.tiles[self.tiles.geometry.area > 0]
385    self.regularised_prototile = copy.deepcopy(self.prototile)
386    return None

Fits the tiles so they sit inside the prototile boundary.

If tiles project outside the boundaries of the prototile, this method will clip them so that they don't. This may result in 'fragmented' tiles, i.e. pieces that would form a single tile after tiling which are separated into fragments.

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

Returns 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 plot( self, ax=None, show_prototile: bool = True, show_reg_prototile: bool = True, show_ids: str = 'tile_id', show_vectors: bool = False, r: int = 0, prototile_edgecolour: str = 'k', reg_prototile_edgecolour: str = 'r', r_alpha: float = 0.3, cmap: list[str] = None, figsize: tuple[float] = (8, 8), **kwargs) -> <function axes at 0x11cecf240>:
420  def plot(
421    self,
422    ax=None,
423    show_prototile: bool = True,
424    show_reg_prototile: bool = True,
425    show_ids: str = "tile_id",
426    show_vectors: bool = False,
427    r: int = 0,
428    prototile_edgecolour: str = "k",
429    reg_prototile_edgecolour: str = "r",
430    r_alpha: float = 0.3,
431    cmap: list[str] = None,
432    figsize: tuple[float] = (8, 8),
433    **kwargs,
434  ) -> pyplot.axes:
435    """Plots a representation of the Tileable on the supplied axis. **kwargs
436    are passed on to matplotlib.plot()
437
438    Args:
439      ax (_type_, optional): matplotlib axis to draw to. Defaults to None.
440      show_prototile (bool, optional): if `True` show the tile outline.
441        Defaults to `True`.
442      show_reg_prototile (bool, optional): if `True` show the regularised tile
443        outline. Defaults to `True`.
444      show_ids (str, optional): if `tile_id` show the tile_ids. If
445        `id` show index number. If None or `""` don't label tiles.
446        Defaults to `tile_id`.
447      show_vectors (bool, optional): if `True` show the translation
448        vectors (not the minimal pair, but those used by
449        `get_local_patch()`). Defaults to `False`.
450      r (int, optional): passed to `get_local_patch()` to show context if
451        greater than 0. Defaults to `0`.
452      r_alpha (float, optional): alpha setting for units other than the
453        central one. Defaults to 0.3.
454      prototile_edgecolour (str, optional): outline colour for the tile.
455        Defaults to `"k"`.
456      reg_prototile_edgecolour (str, optional): outline colour for the
457        regularised. Defaults to `"r"`.
458      cmap (list[str], optional): colour map to apply to the central
459        tiles. Defaults to `None`.
460      figsize (tuple[float], optional): size of the figure.
461        Defaults to `(8, 8)`.
462    """
463    w = self.prototile.geometry[0].bounds[2] - \
464      self.prototile.geometry[0].bounds[0]
465    n_cols = len(set(self.tiles.tile_id))
466    if cmap is None:
467      cm = "Dark2" if n_cols <= 8 else "Paired"
468    else:
469      cm = cmap
470    if ax is None:
471      ax = self.tiles.plot(
472        column="tile_id", cmap=cm, figsize=figsize, **kwargs)
473    else:
474      self.tiles.plot(
475        ax=ax, column="tile_id", cmap=cm, figsize=figsize, **kwargs)
476    if show_ids != None and show_ids != "":
477      do_label = True
478      if show_ids == "tile_id" or show_ids == True:
479        labels = self.tiles.tile_id
480      elif show_ids == "id":
481        labels = [str(i) for i in range(self.tiles.shape[0])]
482      else:
483        do_label = False
484      if do_label:
485        for id, tile in zip(labels, self.tiles.geometry):
486          ax.annotate(id, (tile.centroid.x, tile.centroid.y),
487            ha = "center", va = "center", bbox = {"lw": 0, "fc": "#ffffff40"})
488    if r > 0:
489      self.get_local_patch(r=r).plot(
490        ax = ax, column = "tile_id", alpha = r_alpha, cmap = cm, **kwargs)
491    if show_prototile:
492      self.prototile.plot(ax = ax, ec = prototile_edgecolour, lw = 0.5, 
493                          fc = "#00000000", **kwargs)
494    if show_vectors:  # note that arrows in mpl are dimensioned in plotspace
495      vecs = self.get_vectors()
496      for v in vecs[: len(vecs) // 2]:
497        ax.arrow(0, 0, v[0], v[1], color = "k", width = w * 0.002,
498          head_width = w * 0.05, length_includes_head = True, zorder = 3)
499    if show_reg_prototile:
500      self.regularised_prototile.plot(
501        ax = ax, ec = reg_prototile_edgecolour, fc = "#00000000", 
502        lw = 1.5, zorder = 2, **kwargs)
503      # ax.annotate("""NOTE regularised prototile (red) indicative\nonly. Prototile and vectors (black) actually\ndo tiling. Regularised prototiles for weave\nunits are particularly problematic!""", 
504      #             xycoords = "axes fraction", xy = (0.01, 0.99), ha = "left", 
505      #             va = "top", bbox = {"lw": 0, "fc": "#ffffff40"})
506    return ax

Plots a representation of the 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.3.
  • 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".
  • 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).
def transform_scale( self, xscale: float = 1.0, yscale: float = 1.0) -> Tileable:
521  def transform_scale(self, xscale: float = 1.0, yscale: float = 1.0) -> "Tileable":
522    """Transforms tileable by scaling.
523
524    Args:
525      xscale (float, optional): x scale factor. Defaults to 1.0.
526      yscale (float, optional): y scale factor. Defaults to 1.0.
527
528    Returns:
529      Tileable: the transformed Tileable.
530    """
531    result = copy.deepcopy(self)
532    result.tiles.geometry = tiling_utils.gridify(
533      self.tiles.geometry.scale(xscale, yscale, origin=(0, 0)))
534    result.prototile.geometry = tiling_utils.gridify(
535      self.prototile.geometry.scale(xscale, yscale, origin=(0, 0)))
536    result.regularised_prototile.geometry = tiling_utils.gridify(
537      self.regularised_prototile.geometry.scale(xscale, yscale, origin=(0, 0)))
538    result.setup_vectors()
539    return result

Transforms tileable by scaling.

Arguments:
  • xscale (float, optional): x scale factor. Defaults to 1.0.
  • yscale (float, optional): y scale factor. Defaults to 1.0.
Returns:

Tileable: the transformed Tileable.

def transform_rotate(self, angle: float = 0.0) -> Tileable:
542  def transform_rotate(self, angle: float = 0.0) -> "Tileable":
543    """Transforms tiling by rotation.
544
545    Args:
546      angle (float, optional): angle to rotate by. Defaults to 0.0.
547
548    Returns:
549      Tileable: the transformed Tileable.
550    """
551    result = copy.deepcopy(self)
552    result.tiles.geometry = tiling_utils.gridify(
553      self.tiles.geometry.rotate(angle, origin=(0, 0)))
554    result.prototile.geometry = tiling_utils.gridify(
555      self.prototile.geometry.rotate(angle, origin=(0, 0)))
556    result.regularised_prototile.geometry = tiling_utils.gridify(
557      self.regularised_prototile.geometry.rotate(angle, origin=(0, 0)))
558    result.setup_vectors()
559    result.rotation = result.rotation + angle
560    return result

Transforms tiling by rotation.

Arguments:
  • angle (float, optional): angle to rotate by. Defaults to 0.0.
Returns:

Tileable: the transformed Tileable.

def transform_skew(self, xa: float = 0.0, ya: float = 0.0) -> Tileable:
563  def transform_skew(self, xa: float = 0.0, ya: float = 0.0) -> "Tileable":
564    """Transforms tiling by skewing
565
566    Args:
567      xa (float, optional): x direction skew. Defaults to 0.0.
568      ya (float, optional): y direction skew. Defaults to 0.0.
569
570    Returns:
571      Tileable: the transformed Tileable.
572    """
573    result = copy.deepcopy(self)
574    result.tiles.geometry = tiling_utils.gridify(
575      self.tiles.geometry.skew(xa, ya, origin=(0, 0)))
576    result.prototile.geometry = tiling_utils.gridify(
577      self.prototile.geometry.skew(xa, ya, origin=(0, 0)))
578    result.regularised_prototile.geometry = tiling_utils.gridify(
579      self.regularised_prototile.geometry.skew(xa, ya, origin=(0, 0)))
580    result.setup_vectors()
581    return result

Transforms tiling by skewing

Arguments:
  • xa (float, optional): x direction skew. Defaults to 0.0.
  • ya (float, optional): y direction skew. Defaults to 0.0.
Returns:

Tileable: the transformed Tileable.