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