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.loc[0, "geometry"] = reg_prototile
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.loc[0, "geometry"] = new_reg_prototile
246    # self.regularised_prototile.geometry[0] = new_reg_prototile
247    return None
248
249
250  def regularise_tiles(self) -> None:
251    """Combines separate tiles that share a tile_id value into
252    single tiles, if they would end up touching after tiling.
253
254    Also adjusts the `Tileable.regularised_prototile`
255    attribute accordingly.
256    """
257    self.regularised_prototile = copy.deepcopy(self.prototile)
258    # This preserves order while finding uniques, unlike list(set()).
259    # Reordering ids might cause confusion when colour palettes
260    # are not assigned explicitly to each id, but in the order
261    # encountered in the tile_id Series of the GeoDataFrame.
262    tiles, tile_ids = [], []
263    ids = list(self.tiles.tile_id.unique())
264    for id in ids:
265      fragment_set = list(
266        self.tiles[self.tiles.tile_id == id].geometry)
267      merge_result = self.merge_fragments(fragment_set)
268      tiles.extend(merge_result)
269      tile_ids.extend([id] * len(merge_result))
270
271    self.tiles = gpd.GeoDataFrame(
272      data = {"tile_id": tile_ids},
273      crs = self.crs,
274      geometry = gpd.GeoSeries([tiling_utils.get_clean_polygon(t) 
275                                for t in tiles]))
276
277    self.regularised_prototile = \
278      self.regularised_prototile.explode(ignore_index = True)
279    if self.regularised_prototile.shape[0] > 1:
280      self.regularised_prototile.geometry = tiling_utils.get_largest_polygon(
281        self.regularised_prototile.geometry)
282    return None
283
284
285  def get_local_patch(self, r: int = 1,
286                      include_0: bool = False) -> gpd.GeoDataFrame:
287    """Returns a GeoDataFrame with translated copies of the Tileable.
288
289    The geodataframe takes the same form as the `Tileable.tile` attribute.
290
291    Args:
292      r (int, optional): the number of 'layers' out from the unit to
293        which the translate copies will extendt. Defaults to `1`.
294      include_0 (bool, optional): If True includes the Tileable itself at
295        (0, 0). Defaults to `False`.
296
297    Returns:
298      gpd.GeoDataFrame: A GeoDataframe of the tiles extended by a number 
299        of 'layers'.
300    """
301    # a dictionary of all the vectors we need, starting with (0, 0)
302    vecs = (
303      {(0, 0, 0): (0, 0)}
304      if self.base_shape in (TileShape.HEXAGON,)
305      else {(0, 0): (0, 0)}
306    )
307    steps = r if self.base_shape in (TileShape.HEXAGON,) else r * 2
308    # a dictionary of the last 'layer' of added vectors
309    last_vecs = copy.deepcopy(vecs)
310    # get the translation vectors in a dictionary indexed by coordinates
311    # we keep track of the sum of vectors using the (integer) coordinates
312    # to avoid duplication of moves due to floating point inaccuracies
313    vectors = self.get_vectors(as_dict = True)
314    for i in range(steps):
315      new_vecs = {}
316      for k1, v1 in last_vecs.items():
317        for k2, v2 in vectors.items():
318          # add the coordinates to make a new key...
319          new_key = tuple([k1[i] + k2[i] for i in range(len(k1))])
320          # ... and the vector components to make a new value
321          new_val = (v1[0] + v2[0], v1[1] + v2[1])
322          # if we haven't reached here before store it
323          if not new_key in vecs:
324            new_vecs[new_key] = new_val
325      # extend the vectors and set the last layer to the set just added
326      vecs = vecs | new_vecs
327      last_vecs = new_vecs
328    if not include_0:  # throw away the identity vector
329      vecs.pop((0, 0, 0) if self.base_shape in (TileShape.HEXAGON,) else (0, 0))
330    ids, tiles = [], []
331    # 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:
332    #
333    #      5 4 3 4 5
334    #      4 2 1 2 4
335    #      3 1 0 1 3
336    #      4 2 1 2 4
337    #      5 4 3 4 5
338    #
339    # this is important for topology detection, where filtering back to the
340    # local patch of radius 1 is greatly eased if prototiles have been added in 
341    # this order. We use the vector index tuples not the euclidean distances
342    # because this may be more resistant to odd effects for non-convex tiles
343    extent = self.prototile.geometry.scale(
344      2 * r + tiling_utils.RESOLUTION, 2 * r + tiling_utils.RESOLUTION,
345      origin = self.prototile.geometry[0].centroid)[0]
346    vector_lengths = {index: np.sqrt(sum([_ ** 2 for _ in index]))
347                      for index in vecs.keys()}
348    ordered_vector_keys = [k for k, v in sorted(vector_lengths.items(), 
349                                                key = lambda item: item[1])]
350    for k in ordered_vector_keys:
351      v = vecs[k]
352      if geom.Point(v[0], v[1]).within(extent):
353        ids.extend(self.tiles.tile_id)
354        tiles.extend(
355          self.tiles.geometry.apply(affine.translate, xoff = v[0], yoff = v[1]))
356    return gpd.GeoDataFrame(
357      data = {"tile_id": ids}, crs=self.crs,
358      geometry = tiling_utils.gridify(gpd.GeoSeries(tiles))
359    )
360
361
362  def fit_tiles_to_prototile(self, centre_tile: int = 0) -> None:
363    """Fits the tiles so they sit inside the prototile boundary.
364
365    If tiles project outside the boundaries of the prototile, this
366    method will clip them so that they don't. This may result in
367    'fragmented' tiles, i.e. pieces that would form a single tile
368    after tiling which are separated into fragments.
369
370    Args:
371      centre_tile (int, optional): the index position of the central
372        tile. Defaults to `0`.
373    """
374    dxy = self.tiles.geometry[centre_tile].centroid
375    self.tiles.geometry = self.tiles.translate(-dxy.x, -dxy.y)
376    # use r = 2 because rectangular tiles may need diagonal neighbours
377    patch = (
378      self.get_local_patch(r=2, include_0=True)
379      if self.base_shape in (TileShape.RECTANGLE,)
380      else self.get_local_patch(r=1, include_0=True)
381    )
382    self.tiles = patch.clip(self.prototile)
383    # repair any weirdness...
384    self.tiles.geometry = tiling_utils.repair_polygon(self.tiles.geometry)
385    self.tiles = self.tiles[self.tiles.geometry.area > 0]
386    self.regularised_prototile = copy.deepcopy(self.prototile)
387    return None
388
389
390  # applicable to both TileUnits and WeaveUnits
391  def inset_tiles(self, inset: float = 0) -> "Tileable":
392    """Returns a new Tileable with an inset applied around the tiles.
393
394    Works by applying a negative buffer of specfied size to all tiles.
395    Tiles that collapse to zero area are removed and the tile_id
396    attribute updated accordingly.
397
398    NOTE: this method is likely to not preserve the relative area of tiles.
399
400    Args:
401      inset (float, optional): The distance to inset. Defaults to `0`.
402
403    Returns:
404      "Tileable": the new inset Tileable.
405    """
406    inset_tiles, inset_ids = [], []
407    for p, id in zip(self.tiles.geometry, self.tiles.tile_id):
408      b = p.buffer(-inset, join_style = 2, cap_style = 3)
409      if not b.area <= 0:
410        inset_tiles.append(b)
411        inset_ids.append(id)
412    result = copy.deepcopy(self)
413    result.tiles = gpd.GeoDataFrame(
414      data={"tile_id": inset_ids},
415      crs=self.crs,
416      geometry=gpd.GeoSeries(inset_tiles),
417    )
418    return result
419
420
421  def plot(self, ax = None, show_prototile: bool = True, 
422    show_reg_prototile: bool = True, show_ids: str = "tile_id",
423    show_vectors: bool = False, r: int = 0, prototile_edgecolour: str = "k", 
424    reg_prototile_edgecolour: str = "r", r_alpha: float = 0.3, 
425    cmap: list[str] = None, figsize: tuple[float] = (8, 8), **kwargs) -> pyplot.axes:
426    """Plots a representation of the Tileable on the supplied axis. **kwargs
427    are passed on to matplotlib.plot()
428
429    Args:
430      ax (_type_, optional): matplotlib axis to draw to. Defaults to None.
431      show_prototile (bool, optional): if `True` show the tile outline.
432        Defaults to `True`.
433      show_reg_prototile (bool, optional): if `True` show the regularised tile
434        outline. Defaults to `True`.
435      show_ids (str, optional): if `tile_id` show the tile_ids. If
436        `id` show index number. If None or `''` don't label tiles.
437        Defaults to `tile_id`.
438      show_vectors (bool, optional): if `True` show the translation
439        vectors (not the minimal pair, but those used by
440        `get_local_patch()`). Defaults to `False`.
441      r (int, optional): passed to `get_local_patch()` to show context if
442        greater than 0. Defaults to `0`.
443      r_alpha (float, optional): alpha setting for units other than the
444        central one. Defaults to 0.3.
445      prototile_edgecolour (str, optional): outline colour for the tile.
446        Defaults to `"k"`.
447      reg_prototile_edgecolour (str, optional): outline colour for the
448        regularised. Defaults to `"r"`.
449      cmap (list[str], optional): colour map to apply to the central
450        tiles. Defaults to `None`.
451      figsize (tuple[float], optional): size of the figure.
452        Defaults to `(8, 8)`.
453    
454    Returns:
455      pyplot.axes: to which calling context may add things.
456    """
457    w = self.prototile.geometry[0].bounds[2] - \
458      self.prototile.geometry[0].bounds[0]
459    n_cols = len(set(self.tiles.tile_id))
460    if cmap is None:
461      cm = "Dark2" if n_cols <= 8 else "Paired"
462    else:
463      cm = cmap
464    if ax is None:
465      ax = self.tiles.plot(
466        column="tile_id", cmap=cm, figsize=figsize, **kwargs)
467    else:
468      self.tiles.plot(
469        ax=ax, column="tile_id", cmap=cm, figsize=figsize, **kwargs)
470    if show_ids != None and show_ids != "":
471      do_label = True
472      if show_ids == "tile_id" or show_ids == True:
473        labels = self.tiles.tile_id
474      elif show_ids == "id":
475        labels = [str(i) for i in range(self.tiles.shape[0])]
476      else:
477        do_label = False
478      if do_label:
479        for id, tile in zip(labels, self.tiles.geometry):
480          ax.annotate(id, (tile.centroid.x, tile.centroid.y),
481            ha = "center", va = "center", bbox = {"lw": 0, "fc": "#ffffff40"})
482    if r > 0:
483      self.get_local_patch(r=r).plot(
484        ax = ax, column = "tile_id", alpha = r_alpha, cmap = cm, **kwargs)
485    if show_prototile:
486      self.prototile.plot(ax = ax, ec = prototile_edgecolour, lw = 0.5, 
487                          fc = "#00000000", **kwargs)
488    if show_vectors:  # note that arrows in mpl are dimensioned in plotspace
489      vecs = self.get_vectors()
490      for v in vecs[: len(vecs) // 2]:
491        ax.arrow(0, 0, v[0], v[1], color = "k", width = w * 0.002,
492          head_width = w * 0.05, length_includes_head = True, zorder = 3)
493    if show_reg_prototile:
494      self.regularised_prototile.plot(
495        ax = ax, ec = reg_prototile_edgecolour, fc = "#00000000", 
496        lw = 1.5, zorder = 2, **kwargs)
497    return ax
498
499
500  def _get_legend_tiles(self):
501    """Returns the tiles augmented by a rotation column.
502
503    This base implementation may be overridden by specific tile unit types.
504    In particular see
505    `weavingspace.weave_unit.WeaveUnit._get_legend_tiles()`.
506    """
507    tiles = copy.deepcopy(self.tiles)
508    tiles["rotation"] = 0
509    return tiles
510
511
512  def transform_scale(self, xscale: float = 1.0, yscale: float = 1.0) -> "Tileable":
513    """Transforms tileable by scaling.
514
515    Args:
516      xscale (float, optional): x scale factor. Defaults to 1.0.
517      yscale (float, optional): y scale factor. Defaults to 1.0.
518
519    Returns:
520      Tileable: the transformed Tileable.
521    """
522    result = copy.deepcopy(self)
523    result.tiles.geometry = tiling_utils.gridify(
524      self.tiles.geometry.scale(xscale, yscale, origin=(0, 0)))
525    result.prototile.geometry = tiling_utils.gridify(
526      self.prototile.geometry.scale(xscale, yscale, origin=(0, 0)))
527    result.regularised_prototile.geometry = tiling_utils.gridify(
528      self.regularised_prototile.geometry.scale(xscale, yscale, origin=(0, 0)))
529    result.setup_vectors()
530    return result
531
532
533  def transform_rotate(self, angle: float = 0.0) -> "Tileable":
534    """Transforms tiling by rotation.
535
536    Args:
537      angle (float, optional): angle to rotate by. Defaults to 0.0.
538
539    Returns:
540      Tileable: the transformed Tileable.
541    """
542    result = copy.deepcopy(self)
543    result.tiles.geometry = tiling_utils.gridify(
544      self.tiles.geometry.rotate(angle, origin=(0, 0)))
545    result.prototile.geometry = tiling_utils.gridify(
546      self.prototile.geometry.rotate(angle, origin=(0, 0)))
547    result.regularised_prototile.geometry = tiling_utils.gridify(
548      self.regularised_prototile.geometry.rotate(angle, origin=(0, 0)))
549    result.setup_vectors()
550    result.rotation = result.rotation + angle
551    return result
552
553
554  def transform_skew(self, xa: float = 0.0, ya: float = 0.0) -> "Tileable":
555    """Transforms tiling by skewing
556
557    Args:
558      xa (float, optional): x direction skew. Defaults to 0.0.
559      ya (float, optional): y direction skew. Defaults to 0.0.
560
561    Returns:
562      Tileable: the transformed Tileable.
563    """
564    result = copy.deepcopy(self)
565    result.tiles.geometry = tiling_utils.gridify(
566      self.tiles.geometry.skew(xa, ya, origin=(0, 0)))
567    result.prototile.geometry = tiling_utils.gridify(
568      self.prototile.geometry.skew(xa, ya, origin=(0, 0)))
569    result.regularised_prototile.geometry = tiling_utils.gridify(
570      self.regularised_prototile.geometry.skew(xa, ya, origin=(0, 0)))
571    result.setup_vectors()
572    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.loc[0, "geometry"] = reg_prototile
225    # self.regularised_prototile.geometry[0] = reg_prototile
226    return [f for f in fragments if not f.is_empty] # don't return any duds
227
228
229  def reattach_tiles(self) -> None:
230    """Move tiles that are outside the regularised prototile main polygon
231    back inside it adjusting regularised prototile if needed.
232    """
233    reg_prototile = self.regularised_prototile.geometry[0]
234    new_reg_prototile = copy.deepcopy(reg_prototile)
235    new_tiles = list(self.tiles.geometry)
236    for i, p in enumerate(self.tiles.geometry):
237      if np.isclose(reg_prototile.intersection(p).area, p.area):
238        new_tiles[i] = p
239        continue
240      for v in self.vectors.values():
241        t_p = affine.translate(p, v[0], v[1])
242        if reg_prototile.intersects(t_p):
243          new_reg_prototile = new_reg_prototile.union(t_p)
244          new_tiles[i] = t_p
245    self.tiles.geometry = gpd.GeoSeries(new_tiles)
246    self.regularised_prototile.loc[0, "geometry"] = new_reg_prototile
247    # self.regularised_prototile.geometry[0] = new_reg_prototile
248    return None
249
250
251  def regularise_tiles(self) -> None:
252    """Combines separate tiles that share a tile_id value into
253    single tiles, if they would end up touching after tiling.
254
255    Also adjusts the `Tileable.regularised_prototile`
256    attribute accordingly.
257    """
258    self.regularised_prototile = copy.deepcopy(self.prototile)
259    # This preserves order while finding uniques, unlike list(set()).
260    # Reordering ids might cause confusion when colour palettes
261    # are not assigned explicitly to each id, but in the order
262    # encountered in the tile_id Series of the GeoDataFrame.
263    tiles, tile_ids = [], []
264    ids = list(self.tiles.tile_id.unique())
265    for id in ids:
266      fragment_set = list(
267        self.tiles[self.tiles.tile_id == id].geometry)
268      merge_result = self.merge_fragments(fragment_set)
269      tiles.extend(merge_result)
270      tile_ids.extend([id] * len(merge_result))
271
272    self.tiles = gpd.GeoDataFrame(
273      data = {"tile_id": tile_ids},
274      crs = self.crs,
275      geometry = gpd.GeoSeries([tiling_utils.get_clean_polygon(t) 
276                                for t in tiles]))
277
278    self.regularised_prototile = \
279      self.regularised_prototile.explode(ignore_index = True)
280    if self.regularised_prototile.shape[0] > 1:
281      self.regularised_prototile.geometry = tiling_utils.get_largest_polygon(
282        self.regularised_prototile.geometry)
283    return None
284
285
286  def get_local_patch(self, r: int = 1,
287                      include_0: bool = False) -> gpd.GeoDataFrame:
288    """Returns a GeoDataFrame with translated copies of the Tileable.
289
290    The geodataframe takes the same form as the `Tileable.tile` attribute.
291
292    Args:
293      r (int, optional): the number of 'layers' out from the unit to
294        which the translate copies will extendt. Defaults to `1`.
295      include_0 (bool, optional): If True includes the Tileable itself at
296        (0, 0). Defaults to `False`.
297
298    Returns:
299      gpd.GeoDataFrame: A GeoDataframe of the tiles extended by a number 
300        of 'layers'.
301    """
302    # a dictionary of all the vectors we need, starting with (0, 0)
303    vecs = (
304      {(0, 0, 0): (0, 0)}
305      if self.base_shape in (TileShape.HEXAGON,)
306      else {(0, 0): (0, 0)}
307    )
308    steps = r if self.base_shape in (TileShape.HEXAGON,) else r * 2
309    # a dictionary of the last 'layer' of added vectors
310    last_vecs = copy.deepcopy(vecs)
311    # get the translation vectors in a dictionary indexed by coordinates
312    # we keep track of the sum of vectors using the (integer) coordinates
313    # to avoid duplication of moves due to floating point inaccuracies
314    vectors = self.get_vectors(as_dict = True)
315    for i in range(steps):
316      new_vecs = {}
317      for k1, v1 in last_vecs.items():
318        for k2, v2 in vectors.items():
319          # add the coordinates to make a new key...
320          new_key = tuple([k1[i] + k2[i] for i in range(len(k1))])
321          # ... and the vector components to make a new value
322          new_val = (v1[0] + v2[0], v1[1] + v2[1])
323          # if we haven't reached here before store it
324          if not new_key in vecs:
325            new_vecs[new_key] = new_val
326      # extend the vectors and set the last layer to the set just added
327      vecs = vecs | new_vecs
328      last_vecs = new_vecs
329    if not include_0:  # throw away the identity vector
330      vecs.pop((0, 0, 0) if self.base_shape in (TileShape.HEXAGON,) else (0, 0))
331    ids, tiles = [], []
332    # 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:
333    #
334    #      5 4 3 4 5
335    #      4 2 1 2 4
336    #      3 1 0 1 3
337    #      4 2 1 2 4
338    #      5 4 3 4 5
339    #
340    # this is important for topology detection, where filtering back to the
341    # local patch of radius 1 is greatly eased if prototiles have been added in 
342    # this order. We use the vector index tuples not the euclidean distances
343    # because this may be more resistant to odd effects for non-convex tiles
344    extent = self.prototile.geometry.scale(
345      2 * r + tiling_utils.RESOLUTION, 2 * r + tiling_utils.RESOLUTION,
346      origin = self.prototile.geometry[0].centroid)[0]
347    vector_lengths = {index: np.sqrt(sum([_ ** 2 for _ in index]))
348                      for index in vecs.keys()}
349    ordered_vector_keys = [k for k, v in sorted(vector_lengths.items(), 
350                                                key = lambda item: item[1])]
351    for k in ordered_vector_keys:
352      v = vecs[k]
353      if geom.Point(v[0], v[1]).within(extent):
354        ids.extend(self.tiles.tile_id)
355        tiles.extend(
356          self.tiles.geometry.apply(affine.translate, xoff = v[0], yoff = v[1]))
357    return gpd.GeoDataFrame(
358      data = {"tile_id": ids}, crs=self.crs,
359      geometry = tiling_utils.gridify(gpd.GeoSeries(tiles))
360    )
361
362
363  def fit_tiles_to_prototile(self, centre_tile: int = 0) -> None:
364    """Fits the tiles so they sit inside the prototile boundary.
365
366    If tiles project outside the boundaries of the prototile, this
367    method will clip them so that they don't. This may result in
368    'fragmented' tiles, i.e. pieces that would form a single tile
369    after tiling which are separated into fragments.
370
371    Args:
372      centre_tile (int, optional): the index position of the central
373        tile. Defaults to `0`.
374    """
375    dxy = self.tiles.geometry[centre_tile].centroid
376    self.tiles.geometry = self.tiles.translate(-dxy.x, -dxy.y)
377    # use r = 2 because rectangular tiles may need diagonal neighbours
378    patch = (
379      self.get_local_patch(r=2, include_0=True)
380      if self.base_shape in (TileShape.RECTANGLE,)
381      else self.get_local_patch(r=1, include_0=True)
382    )
383    self.tiles = patch.clip(self.prototile)
384    # repair any weirdness...
385    self.tiles.geometry = tiling_utils.repair_polygon(self.tiles.geometry)
386    self.tiles = self.tiles[self.tiles.geometry.area > 0]
387    self.regularised_prototile = copy.deepcopy(self.prototile)
388    return None
389
390
391  # applicable to both TileUnits and WeaveUnits
392  def inset_tiles(self, inset: float = 0) -> "Tileable":
393    """Returns a new Tileable with an inset applied around the tiles.
394
395    Works by applying a negative buffer of specfied size to all tiles.
396    Tiles that collapse to zero area are removed and the tile_id
397    attribute updated accordingly.
398
399    NOTE: this method is likely to not preserve the relative area of tiles.
400
401    Args:
402      inset (float, optional): The distance to inset. Defaults to `0`.
403
404    Returns:
405      "Tileable": the new inset Tileable.
406    """
407    inset_tiles, inset_ids = [], []
408    for p, id in zip(self.tiles.geometry, self.tiles.tile_id):
409      b = p.buffer(-inset, join_style = 2, cap_style = 3)
410      if not b.area <= 0:
411        inset_tiles.append(b)
412        inset_ids.append(id)
413    result = copy.deepcopy(self)
414    result.tiles = gpd.GeoDataFrame(
415      data={"tile_id": inset_ids},
416      crs=self.crs,
417      geometry=gpd.GeoSeries(inset_tiles),
418    )
419    return result
420
421
422  def plot(self, ax = None, show_prototile: bool = True, 
423    show_reg_prototile: bool = True, show_ids: str = "tile_id",
424    show_vectors: bool = False, r: int = 0, prototile_edgecolour: str = "k", 
425    reg_prototile_edgecolour: str = "r", r_alpha: float = 0.3, 
426    cmap: list[str] = None, figsize: tuple[float] = (8, 8), **kwargs) -> pyplot.axes:
427    """Plots a representation of the Tileable on the supplied axis. **kwargs
428    are passed on to matplotlib.plot()
429
430    Args:
431      ax (_type_, optional): matplotlib axis to draw to. Defaults to None.
432      show_prototile (bool, optional): if `True` show the tile outline.
433        Defaults to `True`.
434      show_reg_prototile (bool, optional): if `True` show the regularised tile
435        outline. Defaults to `True`.
436      show_ids (str, optional): if `tile_id` show the tile_ids. If
437        `id` show index number. If None or `''` don't label tiles.
438        Defaults to `tile_id`.
439      show_vectors (bool, optional): if `True` show the translation
440        vectors (not the minimal pair, but those used by
441        `get_local_patch()`). Defaults to `False`.
442      r (int, optional): passed to `get_local_patch()` to show context if
443        greater than 0. Defaults to `0`.
444      r_alpha (float, optional): alpha setting for units other than the
445        central one. Defaults to 0.3.
446      prototile_edgecolour (str, optional): outline colour for the tile.
447        Defaults to `"k"`.
448      reg_prototile_edgecolour (str, optional): outline colour for the
449        regularised. Defaults to `"r"`.
450      cmap (list[str], optional): colour map to apply to the central
451        tiles. Defaults to `None`.
452      figsize (tuple[float], optional): size of the figure.
453        Defaults to `(8, 8)`.
454    
455    Returns:
456      pyplot.axes: to which calling context may add things.
457    """
458    w = self.prototile.geometry[0].bounds[2] - \
459      self.prototile.geometry[0].bounds[0]
460    n_cols = len(set(self.tiles.tile_id))
461    if cmap is None:
462      cm = "Dark2" if n_cols <= 8 else "Paired"
463    else:
464      cm = cmap
465    if ax is None:
466      ax = self.tiles.plot(
467        column="tile_id", cmap=cm, figsize=figsize, **kwargs)
468    else:
469      self.tiles.plot(
470        ax=ax, column="tile_id", cmap=cm, figsize=figsize, **kwargs)
471    if show_ids != None and show_ids != "":
472      do_label = True
473      if show_ids == "tile_id" or show_ids == True:
474        labels = self.tiles.tile_id
475      elif show_ids == "id":
476        labels = [str(i) for i in range(self.tiles.shape[0])]
477      else:
478        do_label = False
479      if do_label:
480        for id, tile in zip(labels, self.tiles.geometry):
481          ax.annotate(id, (tile.centroid.x, tile.centroid.y),
482            ha = "center", va = "center", bbox = {"lw": 0, "fc": "#ffffff40"})
483    if r > 0:
484      self.get_local_patch(r=r).plot(
485        ax = ax, column = "tile_id", alpha = r_alpha, cmap = cm, **kwargs)
486    if show_prototile:
487      self.prototile.plot(ax = ax, ec = prototile_edgecolour, lw = 0.5, 
488                          fc = "#00000000", **kwargs)
489    if show_vectors:  # note that arrows in mpl are dimensioned in plotspace
490      vecs = self.get_vectors()
491      for v in vecs[: len(vecs) // 2]:
492        ax.arrow(0, 0, v[0], v[1], color = "k", width = w * 0.002,
493          head_width = w * 0.05, length_includes_head = True, zorder = 3)
494    if show_reg_prototile:
495      self.regularised_prototile.plot(
496        ax = ax, ec = reg_prototile_edgecolour, fc = "#00000000", 
497        lw = 1.5, zorder = 2, **kwargs)
498    return ax
499
500
501  def _get_legend_tiles(self):
502    """Returns the tiles augmented by a rotation column.
503
504    This base implementation may be overridden by specific tile unit types.
505    In particular see
506    `weavingspace.weave_unit.WeaveUnit._get_legend_tiles()`.
507    """
508    tiles = copy.deepcopy(self.tiles)
509    tiles["rotation"] = 0
510    return tiles
511
512
513  def transform_scale(self, xscale: float = 1.0, yscale: float = 1.0) -> "Tileable":
514    """Transforms tileable by scaling.
515
516    Args:
517      xscale (float, optional): x scale factor. Defaults to 1.0.
518      yscale (float, optional): y scale factor. Defaults to 1.0.
519
520    Returns:
521      Tileable: the transformed Tileable.
522    """
523    result = copy.deepcopy(self)
524    result.tiles.geometry = tiling_utils.gridify(
525      self.tiles.geometry.scale(xscale, yscale, origin=(0, 0)))
526    result.prototile.geometry = tiling_utils.gridify(
527      self.prototile.geometry.scale(xscale, yscale, origin=(0, 0)))
528    result.regularised_prototile.geometry = tiling_utils.gridify(
529      self.regularised_prototile.geometry.scale(xscale, yscale, origin=(0, 0)))
530    result.setup_vectors()
531    return result
532
533
534  def transform_rotate(self, angle: float = 0.0) -> "Tileable":
535    """Transforms tiling by rotation.
536
537    Args:
538      angle (float, optional): angle to rotate by. Defaults to 0.0.
539
540    Returns:
541      Tileable: the transformed Tileable.
542    """
543    result = copy.deepcopy(self)
544    result.tiles.geometry = tiling_utils.gridify(
545      self.tiles.geometry.rotate(angle, origin=(0, 0)))
546    result.prototile.geometry = tiling_utils.gridify(
547      self.prototile.geometry.rotate(angle, origin=(0, 0)))
548    result.regularised_prototile.geometry = tiling_utils.gridify(
549      self.regularised_prototile.geometry.rotate(angle, origin=(0, 0)))
550    result.setup_vectors()
551    result.rotation = result.rotation + angle
552    return result
553
554
555  def transform_skew(self, xa: float = 0.0, ya: float = 0.0) -> "Tileable":
556    """Transforms tiling by skewing
557
558    Args:
559      xa (float, optional): x direction skew. Defaults to 0.0.
560      ya (float, optional): y direction skew. Defaults to 0.0.
561
562    Returns:
563      Tileable: the transformed Tileable.
564    """
565    result = copy.deepcopy(self)
566    result.tiles.geometry = tiling_utils.gridify(
567      self.tiles.geometry.skew(xa, ya, origin=(0, 0)))
568    result.prototile.geometry = tiling_utils.gridify(
569      self.prototile.geometry.skew(xa, ya, origin=(0, 0)))
570    result.regularised_prototile.geometry = tiling_utils.gridify(
571      self.regularised_prototile.geometry.skew(xa, ya, origin=(0, 0)))
572    result.setup_vectors()
573    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.loc[0, "geometry"] = reg_prototile
225    # self.regularised_prototile.geometry[0] = reg_prototile
226    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.

Args: 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:
229  def reattach_tiles(self) -> None:
230    """Move tiles that are outside the regularised prototile main polygon
231    back inside it adjusting regularised prototile if needed.
232    """
233    reg_prototile = self.regularised_prototile.geometry[0]
234    new_reg_prototile = copy.deepcopy(reg_prototile)
235    new_tiles = list(self.tiles.geometry)
236    for i, p in enumerate(self.tiles.geometry):
237      if np.isclose(reg_prototile.intersection(p).area, p.area):
238        new_tiles[i] = p
239        continue
240      for v in self.vectors.values():
241        t_p = affine.translate(p, v[0], v[1])
242        if reg_prototile.intersects(t_p):
243          new_reg_prototile = new_reg_prototile.union(t_p)
244          new_tiles[i] = t_p
245    self.tiles.geometry = gpd.GeoSeries(new_tiles)
246    self.regularised_prototile.loc[0, "geometry"] = new_reg_prototile
247    # self.regularised_prototile.geometry[0] = new_reg_prototile
248    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:
251  def regularise_tiles(self) -> None:
252    """Combines separate tiles that share a tile_id value into
253    single tiles, if they would end up touching after tiling.
254
255    Also adjusts the `Tileable.regularised_prototile`
256    attribute accordingly.
257    """
258    self.regularised_prototile = copy.deepcopy(self.prototile)
259    # This preserves order while finding uniques, unlike list(set()).
260    # Reordering ids might cause confusion when colour palettes
261    # are not assigned explicitly to each id, but in the order
262    # encountered in the tile_id Series of the GeoDataFrame.
263    tiles, tile_ids = [], []
264    ids = list(self.tiles.tile_id.unique())
265    for id in ids:
266      fragment_set = list(
267        self.tiles[self.tiles.tile_id == id].geometry)
268      merge_result = self.merge_fragments(fragment_set)
269      tiles.extend(merge_result)
270      tile_ids.extend([id] * len(merge_result))
271
272    self.tiles = gpd.GeoDataFrame(
273      data = {"tile_id": tile_ids},
274      crs = self.crs,
275      geometry = gpd.GeoSeries([tiling_utils.get_clean_polygon(t) 
276                                for t in tiles]))
277
278    self.regularised_prototile = \
279      self.regularised_prototile.explode(ignore_index = True)
280    if self.regularised_prototile.shape[0] > 1:
281      self.regularised_prototile.geometry = tiling_utils.get_largest_polygon(
282        self.regularised_prototile.geometry)
283    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:
286  def get_local_patch(self, r: int = 1,
287                      include_0: bool = False) -> gpd.GeoDataFrame:
288    """Returns a GeoDataFrame with translated copies of the Tileable.
289
290    The geodataframe takes the same form as the `Tileable.tile` attribute.
291
292    Args:
293      r (int, optional): the number of 'layers' out from the unit to
294        which the translate copies will extendt. Defaults to `1`.
295      include_0 (bool, optional): If True includes the Tileable itself at
296        (0, 0). Defaults to `False`.
297
298    Returns:
299      gpd.GeoDataFrame: A GeoDataframe of the tiles extended by a number 
300        of 'layers'.
301    """
302    # a dictionary of all the vectors we need, starting with (0, 0)
303    vecs = (
304      {(0, 0, 0): (0, 0)}
305      if self.base_shape in (TileShape.HEXAGON,)
306      else {(0, 0): (0, 0)}
307    )
308    steps = r if self.base_shape in (TileShape.HEXAGON,) else r * 2
309    # a dictionary of the last 'layer' of added vectors
310    last_vecs = copy.deepcopy(vecs)
311    # get the translation vectors in a dictionary indexed by coordinates
312    # we keep track of the sum of vectors using the (integer) coordinates
313    # to avoid duplication of moves due to floating point inaccuracies
314    vectors = self.get_vectors(as_dict = True)
315    for i in range(steps):
316      new_vecs = {}
317      for k1, v1 in last_vecs.items():
318        for k2, v2 in vectors.items():
319          # add the coordinates to make a new key...
320          new_key = tuple([k1[i] + k2[i] for i in range(len(k1))])
321          # ... and the vector components to make a new value
322          new_val = (v1[0] + v2[0], v1[1] + v2[1])
323          # if we haven't reached here before store it
324          if not new_key in vecs:
325            new_vecs[new_key] = new_val
326      # extend the vectors and set the last layer to the set just added
327      vecs = vecs | new_vecs
328      last_vecs = new_vecs
329    if not include_0:  # throw away the identity vector
330      vecs.pop((0, 0, 0) if self.base_shape in (TileShape.HEXAGON,) else (0, 0))
331    ids, tiles = [], []
332    # 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:
333    #
334    #      5 4 3 4 5
335    #      4 2 1 2 4
336    #      3 1 0 1 3
337    #      4 2 1 2 4
338    #      5 4 3 4 5
339    #
340    # this is important for topology detection, where filtering back to the
341    # local patch of radius 1 is greatly eased if prototiles have been added in 
342    # this order. We use the vector index tuples not the euclidean distances
343    # because this may be more resistant to odd effects for non-convex tiles
344    extent = self.prototile.geometry.scale(
345      2 * r + tiling_utils.RESOLUTION, 2 * r + tiling_utils.RESOLUTION,
346      origin = self.prototile.geometry[0].centroid)[0]
347    vector_lengths = {index: np.sqrt(sum([_ ** 2 for _ in index]))
348                      for index in vecs.keys()}
349    ordered_vector_keys = [k for k, v in sorted(vector_lengths.items(), 
350                                                key = lambda item: item[1])]
351    for k in ordered_vector_keys:
352      v = vecs[k]
353      if geom.Point(v[0], v[1]).within(extent):
354        ids.extend(self.tiles.tile_id)
355        tiles.extend(
356          self.tiles.geometry.apply(affine.translate, xoff = v[0], yoff = v[1]))
357    return gpd.GeoDataFrame(
358      data = {"tile_id": ids}, crs=self.crs,
359      geometry = tiling_utils.gridify(gpd.GeoSeries(tiles))
360    )

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

Args: centre_tile (int, optional): the index position of the central tile. Defaults to 0.

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

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

Plots a representation of the 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.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).

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

def transform_scale( self, xscale: float = 1.0, yscale: float = 1.0) -> Tileable:
513  def transform_scale(self, xscale: float = 1.0, yscale: float = 1.0) -> "Tileable":
514    """Transforms tileable by scaling.
515
516    Args:
517      xscale (float, optional): x scale factor. Defaults to 1.0.
518      yscale (float, optional): y scale factor. Defaults to 1.0.
519
520    Returns:
521      Tileable: the transformed Tileable.
522    """
523    result = copy.deepcopy(self)
524    result.tiles.geometry = tiling_utils.gridify(
525      self.tiles.geometry.scale(xscale, yscale, origin=(0, 0)))
526    result.prototile.geometry = tiling_utils.gridify(
527      self.prototile.geometry.scale(xscale, yscale, origin=(0, 0)))
528    result.regularised_prototile.geometry = tiling_utils.gridify(
529      self.regularised_prototile.geometry.scale(xscale, yscale, origin=(0, 0)))
530    result.setup_vectors()
531    return result

Transforms tileable by scaling.

Args: 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:
534  def transform_rotate(self, angle: float = 0.0) -> "Tileable":
535    """Transforms tiling by rotation.
536
537    Args:
538      angle (float, optional): angle to rotate by. Defaults to 0.0.
539
540    Returns:
541      Tileable: the transformed Tileable.
542    """
543    result = copy.deepcopy(self)
544    result.tiles.geometry = tiling_utils.gridify(
545      self.tiles.geometry.rotate(angle, origin=(0, 0)))
546    result.prototile.geometry = tiling_utils.gridify(
547      self.prototile.geometry.rotate(angle, origin=(0, 0)))
548    result.regularised_prototile.geometry = tiling_utils.gridify(
549      self.regularised_prototile.geometry.rotate(angle, origin=(0, 0)))
550    result.setup_vectors()
551    result.rotation = result.rotation + angle
552    return result

Transforms tiling by rotation.

Args: 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:
555  def transform_skew(self, xa: float = 0.0, ya: float = 0.0) -> "Tileable":
556    """Transforms tiling by skewing
557
558    Args:
559      xa (float, optional): x direction skew. Defaults to 0.0.
560      ya (float, optional): y direction skew. Defaults to 0.0.
561
562    Returns:
563      Tileable: the transformed Tileable.
564    """
565    result = copy.deepcopy(self)
566    result.tiles.geometry = tiling_utils.gridify(
567      self.tiles.geometry.skew(xa, ya, origin=(0, 0)))
568    result.prototile.geometry = tiling_utils.gridify(
569      self.prototile.geometry.skew(xa, ya, origin=(0, 0)))
570    result.regularised_prototile.geometry = tiling_utils.gridify(
571      self.regularised_prototile.geometry.skew(xa, ya, origin=(0, 0)))
572    result.setup_vectors()
573    return result

Transforms tiling by skewing

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