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
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.
Inherited Members
- enum.Enum
- name
- value
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.
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
the geometries with associated title_id
attribute encoding their
different colouring.
the tileable polygon (rectangle, hexagon or diamond)
polygon containing the tiles of this tileable, usually a union of its tile polygons
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).
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/
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.
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.
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.
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.
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.
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'.
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
.
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.
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 toTrue
. - show_reg_prototile (bool, optional): if
True
show the regularised tile outline. Defaults toTrue
. - show_ids (str, optional): if
tile_id
show the tile_ids. Ifid
show index number. If None or""
don't label tiles. Defaults totile_id
. - show_vectors (bool, optional): if
True
show the translation vectors (not the minimal pair, but those used byget_local_patch()
). Defaults toFalse
. - r (int, optional): passed to
get_local_patch()
to show context if greater than 0. Defaults to0
. - 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)
.
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.
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.
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.