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