weavingspace.tiling_utils
1#!/usr/bin/env python 2# coding: utf-8 3 4# don't understand it but this allows import of TileUnit for type hinting only 5from __future__ import annotations 6from typing import TYPE_CHECKING 7if TYPE_CHECKING: 8 from weavingspace import TileUnit 9from typing import Iterable, Union 10 11import re 12import string 13import copy 14 15import numpy as np 16from scipy import interpolate 17 18import geopandas as gpd 19import pandas as pd 20import shapely.geometry as geom 21import shapely.affinity as affine 22from shapely.testing import assert_geometries_equal 23# import shapely.wkt as wkt 24import shapely.ops 25 26 27"""Utility functions for use by various classes in the `weavingspace` 28package. Most are geometric convenience functions for commonly applied 29operations. 30""" 31 32PRECISION = 6 33RESOLUTION = 1e-6 34 35def _parse_strand_label(s:str) -> list[str]: 36 """Breaks a strand label specification in to a list of labels 37 38 Args: 39 s (str): see get_strand_ids() for details 40 Returns: 41 list[str]: list of strand labels. 42 """ 43 clean_s = re.sub("[(]+", "(", re.sub("[)]+", ")", s)) 44 is_combo = False 45 output = [] 46 current = "" 47 for c in clean_s: 48 if is_combo: 49 if c == ")": 50 output.append(current) 51 current = "" 52 is_combo = False 53 else: 54 current = current + c 55 else: 56 if c == "(": 57 is_combo = True 58 else: 59 output.append(c) 60 return output 61 62 63def get_strand_ids(strands_spec: str) -> tuple[list[str]]: 64 """Conversts a strands specification string to a list of lists of strand 65 labels. 66 67 Args: 68 strands_spec (str): string format "a|bc|(de)f" | separates strands in 69 each direction and () designates combining labels into a single 70 strand that will be sliced lengthwise. Example output: 71 72 "a|bc|(de)f" -> (["a"], ["b", "c"], ["de", "f"]) 73 74 Superflous parentheses are removed, but no other error-checks are 75 applied. 76 77 Returns: 78 tuple[str]: tuple of lists of labels for each set of strands. 79 """ 80 strand_ids = [_parse_strand_label(s) for s in strands_spec.split("|")] 81 strand_ids = strand_ids if len(strand_ids) == 3 else strand_ids + [[""]] 82 return tuple(strand_ids) 83 84 85def centre_offset(shape: geom.Polygon, 86 target:tuple[float] = (0, 0)) -> tuple[float]: 87 """Returns vector required to move centroid of polygon to target. 88 89 Args: 90 shape (Polygon): polygon to move. 91 target (tuple[float], optional): target to move to. 92 Defaults to (0, 0). 93 94 Returns: 95 tuple[float]: tuple of x, y movement required. 96 """ 97 shape_c = shape.centroid 98 return (target[0] - shape_c.x, target[1] - shape_c.y) 99 100 101def get_regular_polygon(spacing, n:int) -> geom.Polygon: 102 """Returns regular polygon with n sides centered on (0, 0) with a horizontal base, and height given by spacing. 103 104 Args: 105 spacing (_type_): required height. 106 n (int): number of sides. 107 108 Returns: 109 geom.Polygon: required geom.Polygon. 110 """ 111 R = spacing / np.cos(np.radians(180 / n)) / 2 112 a0 = -90 + 180 / n 113 a_diff = 360 / n 114 angles = [a0 + a * a_diff for a in range(n)] 115 corners = [(R * np.cos(np.radians(a)), 116 R * np.sin(np.radians(a))) for a in angles] 117 return gridify(geom.Polygon(corners)) 118 119 120def offset_polygon_corners(polygon:geom.Polygon, 121 offset:int) -> geom.Polygon: 122 """Returns this polygon but with its first corner offset from its 123 original position in the coordinate sequence. The returned polygon will 124 be identical but stored differently internally. 125 126 Args: 127 polygon (geom.Polygon): the polygon to reorder. 128 offset (int): the number of corner positions by which to shift the 129 sequence. 130 131 Returns: 132 geom.Polygon: the reordered polygon. 133 """ 134 corners = get_corners(polygon, repeat_first = False) 135 return geom.Polygon([c for c in corners[offset:] + corners[:offset]]) 136 137 138def rotate_preserving_order(polygon:geom.Polygon, angle:float, 139 centre:geom.Point) -> geom.Polygon: 140 """Returns the supplied polygon rotated with the order of its corner points 141 preserved (not guaranteed by shapely.affinity.rotate). 142 143 Args: 144 polygon (geom.Polygon): polygon to rotate. 145 angle (float): desired angle of rotation (in degrees). 146 centre (geom.Point): the rotation centre (passed on to shapely.affinity. 147 rotate). 148 149 Returns: 150 geom.Polygon: rotated polygon. 151 """ 152 corners = get_corners(polygon, repeat_first = False) 153 return geom.Polygon([affine.rotate(c, angle, centre) for c in corners]) 154 155 156def geometry_matches(geom1:geom.Polygon, geom2:geom.Polygon): 157 a = geom1.area 158 return np.isclose(a, geom1.intersection(geom2).area, 159 rtol = RESOLUTION * 100, atol=RESOLUTION * 100) 160 return shapely.equals_exact( 161 geom1.normalize(), geom2.normalize(), RESOLUTION * 10) 162 163 164def is_convex(shape:geom.Polygon) -> bool: 165 """Tests for shape convexity. There are better ways to do this, like 166 e.g. using something like the get_interior_angles() function, but simply 167 checking if the area is close to that of its convex hull works too! 168 169 Args: 170 shape (geom.Polygon): polygon to check 171 172 Returns: 173 bool: True if the polygon is convex, False otherwise. 174 """ 175 return np.isclose(shape.area, shape.convex_hull.area, rtol = RESOLUTION) 176 177 178def get_corners(shape:geom.Polygon, 179 repeat_first:bool = True) -> list[geom.Point]: 180 """Returns a list of geom.Points around the boundary of a polygon, optionally 181 repeating the first. Does no simplification (e.g. if a line segment has a 182 'corner' along its length, it is NOT removed; see get_clean_polygon for 183 that). Points have precision set to the package default tiling_utils. 184 RESOLUTION. 185 186 Args: 187 shape (geom.Polygon): polygon whose corners are required. 188 repeat_first (bool, optional): if True the first corner is repeated in the 189 returned list, if False it is omitted. Defaults to True. 190 191 Returns: 192 list[geom.Point]: list of geom.Point vertices of the polygon. 193 """ 194 corners = [gridify(geom.Point(pt)) for pt in shape.exterior.coords] 195 if repeat_first: 196 return corners 197 else: 198 return corners[:-1] 199 200 201def get_sides(shape:geom.Polygon) -> list[geom.LineString]: 202 """Returns polygon sides as a list of geom.LineStrings, with resolution set 203 to the package default tiling_utils.RESOLUTION. No simplification for 204 successive colinear sides is carried out. 205 206 Args: 207 shape (geom.Polygon): polygon whose edges are required. 208 209 Returns: 210 list[geom.LineString]: list of geom.LineString sides of the polygon. 211 """ 212 corners = get_corners(shape) 213 return [gridify(geom.LineString([p1, p2])) 214 for p1, p2 in zip(corners[:-1], corners[1:])] 215 216 217def get_side_lengths(shape:geom.Polygon) -> list[float]: 218 """Returns list of lengths of polygon sides. No simplification for corners 219 along sides is carried out. 220 221 Args: 222 shape (geom.Polygon): polygon whose edge lengths are required. 223 224 Returns: 225 list[float]: list of side lengths. 226 """ 227 corners = get_corners(shape) 228 return [p1.distance(p2) for p1, p2 in zip(corners[:-1], corners[1:])] 229 230 231def get_side_bearings(shape:geom.Polygon) -> tuple[tuple[float]]: 232 """Returns a list of angles (in degrees) between the sides of a polygon and 233 the positive x-axis, when proceeding from the first point in each side to its 234 end point. This should usually be CW around the polygon. 235 236 Args: 237 shape (geom.Polygon): polygon whose side bearings are required. 238 239 Returns: 240 tuple[tuple[float]]: tuple of bearings of each edge. 241 """ 242 return tuple([np.degrees(np.arctan2( 243 e.coords[1][1] - e.coords[0][1], e.coords[1][0] - e.coords[0][0])) 244 for e in get_sides(shape)]) 245 246 247def get_interior_angles(shape:geom.Polygon) -> list[float]: 248 """Returns angles (in degrees) between successive edges of a polygon. No 249 polygon simplification is carried out so some angles may be 180 (i.e. a 250 'corner' along a side, such that successive sides are colinear). 251 252 Args: 253 shape (geom.Polygon): polygon whose angles are required. 254 255 Returns: 256 list[float]: list of angles. 257 """ 258 corners = get_corners(shape, repeat_first = False) 259 wrap_corners = corners[-1:] + corners + corners[:1] 260 triples = zip(wrap_corners[:-2], wrap_corners[1:-1], wrap_corners[2:]) 261 return [get_inner_angle(p1, p2, p3) for p1, p2, p3 in triples] 262 263 264def get_clean_polygon( 265 shape:Union[geom.MultiPolygon,geom.Polygon]) -> geom.Polygon: 266 """Returns polygon with any successive corners that are co-linear along a 267 side or very close to one another removed. 268 269 Particularly useful for tidying polygons weave tilings that have been 270 assembled from multiple 'cells' in the weave grid. 271 272 Args: 273 shape (Union[geom.MultiPolygon,geom.Polygon]): polygon to clean. 274 275 Returns: 276 geom.Polygon: cleaned polygon. 277 """ 278 if isinstance(shape, geom.MultiPolygon): 279 return geom.MultiPolygon([get_clean_polygon(p) for p in shape.geoms]) 280 else: 281 corners = get_corners(shape, repeat_first = False) 282 # first remove any 'near neighbour' corners 283 distances = get_side_lengths(shape) 284 to_remove = [np.isclose(d, 0, rtol = RESOLUTION, atol = 10 * RESOLUTION) 285 for d in distances] 286 corners = [c for c, r in zip(corners, to_remove) if not r] 287 # next remove any that are colinear 288 p = geom.Polygon(corners) 289 corners = get_corners(p, repeat_first = False) 290 angles = get_interior_angles(p) 291 to_remove = [np.isclose(a, 0, rtol = RESOLUTION, atol = RESOLUTION) or 292 np.isclose(a, 180, rtol = RESOLUTION, atol = RESOLUTION) 293 for a in angles] 294 corners = [c for c, r in zip(corners, to_remove) if not r] 295 return gridify(geom.Polygon(corners)) 296 297 298def get_inner_angle(p1:geom.Point, p2:geom.Point, p3:geom.Point) -> float: 299 r"""Returns the angle (in degrees) between line p1-p2 and p2-p3, i.e., the 300 angle A below 301 302 p2 303 / \ 304 / A \ 305 p1 p3 306 307 Args: 308 p1 (geom.Point): first point. 309 p2 (geom.Point): second 'corner' point. 310 p3 (geom.Point): third point. 311 312 Returns: 313 float: angle in degrees. 314 """ 315 return np.degrees(np.arctan2(p3.y - p2.y, p3.x - p2.x) - 316 np.arctan2(p1.y - p2.y, p1.x - p2.x)) % 360 317 318 319def get_outer_angle(p1:geom.Point, p2:geom.Point, p3:geom.Point) -> float: 320 r"""Returns outer angle (in degrees) between lines p1-p2 and p2-p3, i.e., the 321 angle A below 322 323 / 324 / 325 p2 A 326 / \ 327 / \ 328 p1 p3 329 330 Args: 331 p1 (geom.Point): first point. 332 p2 (geom.Point): second 'corner' point. 333 p3 (geom.Point): third point. 334 335 Returns: 336 float: angle in degrees. 337 """ 338 return 180 - get_inner_angle(p1, p2, p3) 339 340 341def is_regular_polygon(shape:geom.Polygon) -> bool: 342 """Tests if supplied polygon is regular (i.e. equal sides and angles). 343 344 Args: 345 shape (geom.Polygon): polygon to test. 346 347 Returns: 348 bool: True if polygon is regular, False if not. 349 """ 350 side_lengths = get_side_lengths(shape) 351 angles = get_interior_angles(shape) 352 return all(np.isclose(side_lengths, side_lengths[0])) \ 353 and all(np.isclose(angles, angles[0])) 354 355 356def is_tangential(shape:geom.Polygon) -> bool: 357 """Determines if the supplied polygon is tangential i.e., it can have 358 circle inscribed tangential to all its sides. 359 360 Note that this will fail for polygons with successive colinear sides, 361 meaning that polygons should be fully simplified... 362 """ 363 if is_regular_polygon(shape): 364 return True 365 side_lengths = get_side_lengths(shape) 366 n = len(side_lengths) 367 if n % 2 == 1: 368 if n == 3: #triangles are easy! 369 return True 370 # odd number of sides there is a nice solvable system of equations 371 # see https://math.stackexchange.com/questions/4065370/tangential-polygons-conditions-on-edge-lengths 372 mat = np.identity(n) + np.roll(np.identity(n), 1, axis = 1) 373 fractions = np.linalg.inv(mat) @ side_lengths / side_lengths 374 if not(np.isclose(np.mean(fractions), 375 0.5, rtol= RESOLUTION, atol = RESOLUTION)): 376 return False 377 ones = [np.isclose(f, 1, rtol = RESOLUTION, atol = RESOLUTION) 378 for f in fractions] 379 zeros = [np.isclose(f, 0, rtol = RESOLUTION, atol = RESOLUTION) 380 for f in fractions] 381 negatives = [f < 0 for f in fractions] 382 return not (any(ones) or any(zeros) or any(negatives)) 383 elif n == 4: 384 # for quadrilaterals odd and even side lengths are equal 385 return np.isclose(sum(side_lengths[0::2]), 386 sum(side_lengths[1::2]), rtol = RESOLUTION) 387 else: 388 # other even numbers of sides... hard to avoid brute force 389 bisectors = [get_angle_bisector(shape, i) for i in range(n)] 390 intersects = [b1.intersection(b2) for b1, b2 in 391 zip(bisectors, bisectors[1:] + bisectors[:1]) 392 if b1.intersects(b2)] 393 distances = [i1.distance(i2) for i1, i2 in 394 zip(intersects, intersects[1:] + intersects[:1])] 395 return all([d <= 2 * RESOLUTION for d in distances]) 396 397 398def incentre(shape:geom.Polygon) -> geom.Point: 399 """A different polygon centre, which produces better results for some 400 dual tilings where tiles are not regular polygons... see 401 https://en.wikipedia.org/wiki/Incenter 402 403 This method relies on the polygon being tangential, i.e. there is an 404 inscribed circle to which all sides of the polygon are tangent. It will 405 work on all the polygons encountered in the Laves tilings, but is not 406 guaranteed to work on all polygons. 407 408 Given that the polygon is tangential, the radius of the inscribed circle is 409 the [apothem of the polygon](https://en.wikipedia.org/wiki/Apothem) given 410 by 2 x Area / Perimeter. We apply a parallel offset of this size to two 411 sides of the polygon and find their intersection to determine the centre of 412 the circle, givng the incentre of the polygon. 413 414 Args: 415 shape (geom.Polygon): the polygon. 416 417 Returns: 418 geom.Point: the incentre of the polygon. 419 """ 420 if is_regular_polygon(shape): 421 return shape.centroid 422 shape = ensure_cw(shape) 423 if is_convex(shape): 424 if is_tangential(shape): # find the incentre 425 corners = get_corners(shape, repeat_first = False) 426 r = get_apothem_length(shape) 427 e1 = geom.LineString(corners[:2]).parallel_offset(r, side = "right") 428 e2 = geom.LineString(corners[1:3]).parallel_offset(r, side = "right") 429 return e1.intersection(e2) # TODO: add exception if no intersection 430 return shape.centroid 431 432 433def get_apothem_length(shape:geom.Polygon) -> float: 434 return 2 * shape.area / geom.LineString(shape.exterior.coords).length 435 436 437def ensure_cw(shape:geom.Polygon) -> geom.Polygon: 438 """Returns the polygon with its outer boundary vertices in clockwise order. 439 440 It is important to note that shapely.set_precision() imposes clockwise order 441 on polygons, and since it is used widely throughout theses modules, it makes 442 sense to impose this order. 443 444 Args: 445 shape (geom.Polygon): the polygon. 446 447 Returns: 448 geom.Polygon: the polygon in clockwise order. 449 """ 450 if geom.LinearRing(shape.exterior.coords).is_ccw: 451 return shape.reverse() 452 else: 453 return shape 454 455 456def get_angle_bisector(shape:geom.Polygon, v = 0) -> geom.LineString: 457 """Returns a line which is the angle bisector of the specified corner of the 458 supplied polygon. 459 460 Args: 461 shape (geom.Polygon): the polygon 462 v (int, optional): index of the corner whose bisector is required. 463 Defaults to 0. 464 465 Returns: 466 geom.LineString: line which bisects the specified corner. 467 """ 468 pts = get_corners(shape, repeat_first = False) 469 n = len(pts) 470 p0 = pts[v % n] 471 p1 = pts[(v + 1) % n] 472 p2 = pts[(v - 1) % n] 473 d01 = p0.distance(p1) 474 d02 = p0.distance(p2) 475 if d01 < d02: 476 p2 = geom.LineString([p0, p2]).interpolate(d01) 477 else: 478 p1 = geom.LineString([p0, p1]).interpolate(d02) 479 m = geom.Point((p1.x + p2.x) / 2, (p1.y + p2.y) / 2) 480 # we have to scale this to much larger in case of an angle near 180 481 return affine.scale(geom.LineString([p0, m]), 10, 10, origin = p0) 482 483 484def get_side_bisector(shape:geom.Polygon, i:int = 0) -> geom.LineString: 485 return affine.scale(affine.rotate(get_sides(shape)[i], 90), 100, 100) 486 487 488def _get_interior_vertices(tiles:gpd.GeoDataFrame) -> gpd.GeoSeries: 489 """Returns points not on the outer boundary of the supplied set 490 of tiles. 491 492 Args: 493 shapes (gpd.GeoDataFrame): a set of polygons. 494 495 Returns: 496 gpd.GeoSeries: interior vertices of the set of polygons. 497 """ 498 tiles = gridify(tiles.geometry) 499 uu = safe_union(tiles, as_polygon = True) 500 interior_pts = set() 501 for tile in tiles: 502 for corner in tile.exterior.coords: 503 if uu.contains(geom.Point(corner).buffer(1e-3, cap_style = 3)): 504 interior_pts.add(corner) 505 return gridify(gpd.GeoSeries([geom.Point(p) for p in interior_pts])) 506 507 508def gridify( 509 gs:Union[gpd.GeoSeries, gpd.GeoDataFrame, geom.Polygon] 510 ) -> Union[gpd.GeoSeries, gpd.GeoDataFrame, geom.Polygon]: 511 """Returns the supplied GeoSeries rounded to the specified precision. 512 513 Args: 514 gs (gpd.GeoSeries): geometries to gridify. 515 precision (int, optional): digits of precision. Defaults to 6. 516 517 Returns: 518 gpd.GeoSeries: the rounded geometries. 519 """ 520 # return gpd.GeoSeries( 521 # list(gs.apply( 522 # wkt.dumps, rounding_precision = precision).apply(wkt.loads))) 523 if isinstance(gs, (geom.Polygon, geom.Point, geom.MultiPoint, 524 geom.MultiPolygon, geom.LineString)) : 525 return shapely.set_precision(gs, grid_size = RESOLUTION) 526 if isinstance(gs, gpd.GeoSeries): 527 return gpd.GeoSeries( 528 [shapely.set_precision(g, grid_size = RESOLUTION) for g in list(gs)], 529 crs = gs.crs) 530 if isinstance(gs, gpd.GeoDataFrame): 531 gs.geometry = gridify(gs.geometry) 532 return gs 533 534 535def get_dual_tile_unit(unit: TileUnit) -> gpd.GeoDataFrame: 536 """Converts supplied TileUnit to a candidate GeoDataFrame of its dual 537 TileUnit. 538 539 NOTE: this is complicated and not remotely guaranteed to work! 540 a particular issue is that where to place the vertices of the faces 541 of the dual with respect to the tiles in the original is ill-defined. 542 This is because the dual process is topologically not metrically defined, 543 so that exact vertex locations are ambiguous. Tiling duality is defined in 544 Section 4.2 of Grunbaum B, Shephard G C, 1987 _Tilings and Patterns_ (W. H. 545 Freeman and Company, New York) 546 547 NOTE: In general, this method will work only if all supplied tiles are 548 regular polygons. A known exception is if the only non-regular polygons are 549 triangles. 550 551 NOTE: 'clean' polygons are required. If supplied polygons have messy 552 vertices with multiple points where there is only one proper point, bad 553 things are likely to happen! Consider using `clean_polygon()` on the 554 tile geometries. 555 556 Because of the above limitations, we only return a GeoDataFrame 557 for inspection. However some `weavingspace.tile_unit.TileUnit` setup 558 methods in `weavingspace.tiling_geometries` use this method, where we are 559 confident the returned dual is valid. 560 561 Args: 562 unit (TileUnit): the tiling for which the dual is required. 563 564 Returns: 565 gpd.GeoDataFrame: GeoDataFrame that could be the tiles attribute for 566 a TileUnit of the dual tiling. 567 """ 568 # get a local patch of this Tiling 569 local_patch = unit.get_local_patch(r = 1, include_0 = True) 570 # Find the interior points of these tiles - these will be guaranteed 571 # to have a sequence of surrounding tiles incident on them 572 interior_pts = _get_interior_vertices(local_patch) 573 # Compile a list of the polygons incident on the interior points 574 cycles = [] 575 for pt in interior_pts: 576 cycles.append( 577 set([poly_id for poly_id, p in enumerate(local_patch.geometry) 578 if pt.distance(p) < RESOLUTION * 2])) 579 # convert the polygon ID sequences to (centroid.x, centroid.y, ID) tuples 580 dual_faces = [] 581 for cycle in cycles: 582 ids, pts = [], [] 583 for poly_id in cycle: 584 ids.append(local_patch.tile_id[poly_id]) 585 poly = local_patch.geometry[poly_id] 586 pts.append(incentre(poly)) 587 # sort them into CW order so they are well formed 588 sorted_coords = sort_cw([(pt.x, pt.y, id) 589 for pt, id in zip(pts, ids)]) 590 dual_faces.append( 591 (geom.Polygon([(pt_id[0], pt_id[1]) for pt_id in sorted_coords]), 592 "".join([pt_id[2] for pt_id in sorted_coords]))) 593 # ensure the resulting face centroids are inside the original tile 594 # displaced a little to avoid uncertainties at corners/edges 595 # TODO: Check the logic of this - it seems like dumb luck that it works... 596 dual_faces = [(f, id) for f, id in dual_faces 597 if affine.translate( 598 unit.prototile.geometry[0], RESOLUTION * 10, 599 RESOLUTION * 10).contains(f.centroid)] 600 gdf = gpd.GeoDataFrame( 601 data = {"tile_id": [f[1] for f in dual_faces]}, crs = unit.crs, 602 geometry = gridify(gpd.GeoSeries([f[0] for f in dual_faces]))) 603 # ensure no duplicates 604 gdf = gdf.dissolve(by = "tile_id", as_index = False).explode( 605 index_parts = False, ignore_index = True) 606 607 gdf.tile_id = relabel(gdf.tile_id) 608 return gdf 609 610 611def relabel(data:Iterable) -> list: 612 """Returns supplied data reassigned with unique values from 613 string.ascii_letters. 614 615 Args: 616 data (Iterable): the data to relabel 617 618 Returns: 619 list: the reassigned data 620 """ 621 new_data = {} 622 d_count = 0 623 for d in data: 624 if not d in new_data: 625 new_data[d] = string.ascii_letters[d_count] 626 d_count = d_count + 1 627 return [new_data[d] for d in data] 628 629 630def sort_cw(pts_ids:list[tuple[float, float, str]]): 631 """Sorts supplied tuple of x, y, ID into clockwise order relative to their 632 mean centre. 633 634 Args: 635 pts_ids (list[tuple[float, float, str]]): A tuple of a pair of 636 floats and a string. 637 638 Returns: 639 list: a list in the same format as supplied sorted into 640 clockwise order of the point locations. 641 """ 642 x = [p[0] for p in pts_ids] 643 y = [p[1] for p in pts_ids] 644 cx, cy = np.mean(x), np.mean(y) 645 dx = [_ - cx for _ in x] 646 dy = [_ - cy for _ in y] 647 angles = [np.arctan2(dy, dx) for dx, dy in zip(dx, dy)] 648 d = dict(zip(angles, pts_ids)) 649 return [pt_id for angle, pt_id in reversed(sorted(d.items()))] 650 651 652def order_of_pts_cw_around_centre(pts:list[geom.Point], centre:geom.Point): 653 """Returns the order of the supplied points clockwise relative to supplied 654 centre point, i.e. a list of the indices in clockwise order. 655 656 Args: 657 pts (list[geom.Point]): list of points to order. 658 centre (geom.Point): centre relative to which CW order is determined. 659 660 Returns: 661 _type_: list of reordered points. 662 """ 663 dx = [p.x - centre.x for p in pts] 664 dy = [p.y - centre.y for p in pts] 665 angles = [np.arctan2(dy, dx) for dx, dy in zip(dx, dy)] 666 d = dict(zip(angles, range(len(pts)))) 667 return [i for angle, i in reversed(sorted(d.items()))] 668 669 670def write_map_to_layers(gdf:gpd.GeoDataFrame, fname:str = "output.gpkg", 671 tile_var:str = "tile_id") -> None: 672 """Writes supplied GeoDataFrame to a GPKG file with layers based on 673 the tile_var attribute. 674 675 Args: 676 gdf (gpd.GeoDataFrame): the GeoDataFrame. 677 fname (str, optional): filename to write. 678 tile_var (str, optional): the attribute to use to separate 679 output file into layers. Defaults to "tile_id". 680 """ 681 grouped = gdf.groupby(tile_var, as_index = False) 682 for e in pd.Series.unique(gdf[tile_var]): 683 grouped.get_group(e).to_file(fname, layer = e, driver = "GPKG") 684 685 686def get_collapse_distance(geometry:geom.Polygon) -> float: 687 """Returns the distance under which the supplied polygon will shrink 688 to nothing if negatively buffered by that distance. 689 690 Performs a binary search between an upper bound based on the radius of 691 the circle of equal area to the polygon, and 0. 692 693 Args: 694 geometry (geom.Polygon): the polygon. 695 696 Returns: 697 float: its collapse distance. 698 """ 699 if is_convex(geometry): 700 return get_apothem_length(geometry) 701 radius = np.sqrt(geometry.area / np.pi) 702 lower = 0 703 upper = radius 704 delta = 1e12 705 stop_delta = radius / 1000 706 while delta > stop_delta: 707 new_r = (lower + upper) / 2 708 if geometry.buffer(-new_r).area > 0: 709 lower = new_r 710 delta = upper - new_r 711 else: 712 upper = new_r 713 delta = new_r - lower 714 return new_r 715 716 717def get_largest_polygon(polygons:gpd.GeoSeries) -> gpd.GeoSeries: 718 """Returns the largest polygon in a GeoSeries as a GeoSeries of one polygon. 719 720 Args: 721 polygons (gpd.GeoSeries): the set of polygons to pick from. 722 723 Returns: 724 gpd.GeoSeries: the largest polygon. 725 """ 726 actual_polygons = [p for p in polygons.geometry 727 if isinstance(p, (geom.Polygon, geom.MultiPolygon))] 728 areas = [p.area for p in actual_polygons] 729 max_area = max(areas) 730 return gpd.GeoSeries([actual_polygons[areas.index(max_area)]]) 731 732 733def touch_along_an_edge(p1:geom.Polygon, p2:geom.Polygon) -> bool: 734 """Tests if two polygons touch along an edge. 735 736 Checks that the intersection area of the two polygons buffered by 737 a small amount is large enough to indicate that they neighbour at more 738 than a corner. 739 740 Args: 741 p1 (geom.Polygon): First polygon 742 p2 (geom.Polygon): Second polygon 743 744 Returns: 745 bool: True if they neighbour along an edge 746 """ 747 return p1.buffer(RESOLUTION, join_style = 2, cap_style = 3) \ 748 .intersection(p2.buffer(RESOLUTION, join_style = 2, cap_style = 3)) \ 749 .area > 16 * RESOLUTION ** 2 750 751 752def get_width_height_left_bottom(gs:gpd.GeoSeries) -> tuple[float]: 753 """Returns width, height, left and bottom limits of a GeoSeries 754 755 Args: 756 gs (geopandas.GeoSeries): GeoSeries for which limits are required. 757 758 Returns: 759 tuple: four float values of width, height, left and bottom of gs. 760 """ 761 extent = gs.total_bounds 762 return (extent[2] - extent[0], extent[3] - extent[1], 763 extent[0], extent[1]) 764 765 766def get_bounding_ellipse(shapes:gpd.GeoSeries, 767 mag:float = 1.0) -> gpd.GeoSeries: 768 """Returns an ellipse containing the supplied shapes. 769 770 The method used is to calculate the size of square that would contain 771 the shapes, if they had an aspect ratio 1, then stretch the circle in 772 the x, y directions according to the actual aspect ratio of the shapes. 773 774 Args: 775 shapes (gpd.GeoSeries): the shapes to be contained. 776 mag (float, optional): optionally increase the size of the returned 777 ellipse by this scale factor. Defaults to 1.0. 778 779 Returns: 780 gpd.GeoSeries: the set of shapes. 781 """ 782 783 w, h, l, b = get_width_height_left_bottom(shapes) 784 785 c = geom.Point(l + w / 2, b + h / 2) 786 r = min(w, h) * np.sqrt(2) 787 circle = [c.buffer(r)] 788 return gridify(gpd.GeoSeries(circle, crs = shapes.crs).scale( 789 w / r * mag / np.sqrt(2), h / r * mag / np.sqrt(2), origin = c)) 790 791 792def get_tiling_edges(tiles:gpd.GeoSeries) -> gpd.GeoSeries: 793 """Returns linestring GeoSeries from supplied polygon GeoSeries. 794 795 This is used to allow display of edges of tiles in legend when they are 796 masked by an ellipse (if we instead clip polygons then the ellipse edge 797 will also show in the result.) 798 799 Args: 800 shapes (gpd.GeoSeries): Polygons to convert. 801 802 Returns: 803 gpd.GeoSeries: LineStrings from the supplied Polygons. 804 """ 805 tiles = tiles.explode(ignore_index = True) 806 edges = [geom.LineString(p.exterior.coords) for p in tiles] 807 return gpd.GeoSeries(edges, crs = tiles.crs) 808 809 810def get_polygon_sector(polygon:geom.Polygon, start:float = 0.0, 811 end:float = 1.0) -> geom.Polygon: 812 """Returns a sector of the provided Polygon. 813 814 The returned sector is a section of the polygon boundary between the 815 normalized start and end positions, and including the polygon centroid. 816 Should (probably) only be applied to convex polygons. 817 818 Args: 819 shape (geom.Polygon): the Polygon. 820 start (float): normalized start position along the boundary. Defaults to 821 0. 822 end (float): normalized start position along the boundary. Defaults to 823 1. 824 825 Returns: 826 geom.Polygon: the requested polygon sector. 827 """ 828 if start == end: 829 # must return a null polygon since the calling context 830 # expects to get something back... which most likely 831 # is needed to align with other data 832 return geom.Polygon() 833 if start * end < 0: 834 # either side of 0/1 so assume required sector includes 0 835 e1, e2 = min(start, end), max(start, end) 836 arc1 = shapely.ops.substring(geom.LineString(polygon.exterior.coords), 837 np.mod(e1, 1), 1, normalized = True) 838 arc2 = shapely.ops.substring(geom.LineString(polygon.exterior.coords), 839 0, e2, normalized = True) 840 sector = geom.Polygon([polygon.centroid] + 841 list(arc1.coords) + 842 list(arc2.coords)[1:]) 843 else: 844 arc = shapely.ops.substring(geom.LineString(polygon.exterior.coords), 845 start, end, normalized = True) 846 sector = geom.Polygon([polygon.centroid] + list(arc.coords)) 847 return gridify(sector) 848 849 850def repair_polygon( 851 polygon:Union[geom.Polygon, geom.MultiPolygon, gpd.GeoSeries], 852 shrink_then_grow:bool = True) -> Union[geom.Polygon, gpd.GeoSeries]: 853 """Convenience function to 'repair' a shapely polyon or GeoSeries by applying 854 a negative buffer then the same positive buffer. 855 856 Optionally the buffer may be applied in the opposite order (i.e. grow then 857 shrink). This operation may also convert a MultiPolygon that has some 'stray' 858 parts to a Polygon. 859 860 This is method is often unofficially recommended (on stackexchange etc.) 861 even in the shapely docs, to resolve topology issues and extraneous 862 additional vertices appearing when spatial operations are repeatedly 863 applied. 864 865 Args: 866 p (Union[geom.Polygon, gpd.GeoSeries]): Polygon or GeoSeries to clean. 867 res (float, optional): buffer size to use. Defaults to 1e-3. 868 shrink_then_grow (bool, optional): if True the negative buffer is 869 applied first, otherwise the buffer operations are applied in 870 reverse. Defaults to True. 871 872 Returns: 873 Union[geom.Polygon, gpd.GeoSeries]: the cleaned Polygon or GeoSeries. 874 """ 875 if shrink_then_grow: 876 return polygon.buffer( 877 -RESOLUTION * 10, join_style = 2, cap_style = 3).buffer( 878 RESOLUTION * 10, join_style = 2, cap_style = 3) 879 else: 880 return polygon.buffer( 881 RESOLUTION * 10, join_style = 2, cap_style = 3).buffer( 882 -RESOLUTION * 10, join_style = 2, cap_style = 3) 883 884 885def safe_union(gs:gpd.GeoSeries, 886 as_polygon:bool = False) -> Union[gpd.GeoSeries, geom.Polygon]: 887 """Unions the supplied GeoSeries of Polygons while buffering them to avoid 888 gaps and odd internal floating edges. Optionally returns a Polygon or a 889 GeoSeries. 890 891 Frequently when unioning polygons that are ostensibly adjacent 'rogue' 892 internal boundaries remain in the result. We can avoid this by buffering the 893 polygons before unioning them, then reversing the buffer on the unioned 894 shape. 895 896 Args: 897 gs (gpd.GeoSeries): the Polygons to union. 898 res (float, optional): size of the buffer to use. Defaults to 1e-3. 899 as_polygon (bool, optional): if True returns a Polygon, otherwise 900 returns a one Polygon GeoSeries. Defaults to False. 901 902 Returns: 903 Union[gpd.GeoSeries, geom.Polygon]: the resulting union of supplied 904 polygons. 905 """ 906 union = gs.buffer(RESOLUTION * 10, join_style = 2, cap_style = 3) \ 907 .unary_union.buffer(-RESOLUTION * 10, join_style = 2, cap_style = 3) 908 if as_polygon: 909 return gridify(union) 910 else: 911 return gridify(gpd.GeoSeries([union], crs = gs.crs)) 912 913 914def zigzag_between_points( 915 p0:geom.Point, p1: geom.Point, n:int, h:float = 1.0, smoothness: int = 0): 916 917 template_steps = n * 2 + 1 918 r = p0.distance(p1) 919 920 x = np.linspace(0, n * np.pi, template_steps, endpoint = True) 921 y = [np.sin(x) for x in x] 922 s = interpolate.InterpolatedUnivariateSpline(x, y, k = 2) 923 924 spline_steps = (n + smoothness) * 2 + 1 925 xs = np.linspace(0, n * np.pi, spline_steps, endpoint = True) 926 ys = s(xs) 927 928 sfx = 1 / max(x) * r 929 sfy = h * r / 2 930 theta = np.arctan2(p1.y - p0.y, p1.x - p0.x) 931 932 ls = geom.LineString([geom.Point(x, y) for x, y in zip(xs, ys)]) 933 ls = affine.translate(ls, 0, -(ls.bounds[1] + ls.bounds[3]) / 2) 934 ls = affine.scale(ls, xfact = sfx, yfact = sfy, origin = (0, 0)) 935 ls = affine.rotate(ls, theta, (0, 0), use_radians = True) 936 x0, y0 = list(ls.coords)[0] 937 return affine.translate(ls, p0.x - x0, p0.y - y0) 938 939 940def get_translation_transform(dx:float, dy:float) -> list[float]: 941 """Returns the shapely affine transform tuple for a translation. 942 943 Args: 944 dx (float): translation distance in x direction. 945 dy (float): translation distance in y direction. 946 947 Returns: 948 list[float]: a six item list of floats, per the shapely.affinity. 949 affine_transform method, see 950 https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations 951 """ 952 return [1, 0, 0, 1, dx, dy] 953 954 955def get_rotation_transform( 956 angle:float, centre:tuple[float] = None) -> list[float]: 957 """Returns the shapely affine transform tuple for a rotation, optionally 958 about a supplied centre point. 959 960 Args: 961 angle (float): the angle of rotation (in degrees). 962 centre (tuple[float], optional): An option centre location. Defaults to 963 None, which will in turn be converted to (0, 0). 964 965 Returns: 966 list[float]: a six item list of floats, per the shapely.affinity. 967 affine_transform method, see 968 https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations 969 """ 970 if centre is None or np.allclose((0, 0), centre, atol = RESOLUTION): 971 a = np.radians(angle) 972 return [np.cos(a), -np.sin(a), np.sin(a), np.cos(a), 0, 0] 973 974 dx, dy = centre 975 t1 = get_translation_transform(-dx, -dy) 976 r = get_rotation_transform(angle) 977 t2 = get_translation_transform(dx, dy) 978 return combine_transforms([t1, r, t2]) 979 980 981def get_reflection_transform( 982 angle:float, centre:tuple[float] = None) -> list[float]: 983 """Returns a shapely affine transform tuple that will reflect a shape 984 in a line at the specified angle, optionally through a specified centre 985 point. 986 987 Args: 988 angle (float): angle to the x-axis of the line of reflection. 989 centre (tuple[float], optional): point through which the line of 990 reflection passes. Defaults to None, which 991 will in turn be converted to (0, 0). 992 993 Returns: 994 list[float]: a six item list of floats, per the shapely.affinity. 995 affine_transform method, see 996 https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations 997 """ 998 if centre is None or np.allclose((0, 0), centre, atol = RESOLUTION): 999 A = 2 * np.radians(angle) 1000 return (np.cos(A), np.sin(A), np.sin(A), -np.cos(A), 0, 0) 1001 dx, dy = centre 1002 t1 = get_translation_transform(-dx, -dy) 1003 r = get_reflection_transform(angle) 1004 t2 = get_translation_transform(dx, dy) 1005 return combine_transforms([t1, r, t2]) 1006 1007 1008def combine_transforms(transforms:list[list[float]]) -> list[float]: 1009 """Returns a shapely affine transform list that combines the listed 1010 sequence of transforms applied in order. 1011 1012 Args: 1013 transforms (list[list[float]]): sequence of transforms to combine. 1014 1015 Returns: 1016 list[float]: a transform tuple combining the supplied transforms applied 1017 in order, see 1018 https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations 1019 """ 1020 result = np.identity(3) 1021 for t in transforms: 1022 result = as_numpy_matrix(t) @ result 1023 return as_shapely_transform(result) 1024 1025 1026def reverse_transform(transform:list[float]) -> list[float]: 1027 """Returns the inverse shapely affine transform of the supplied transform. 1028 1029 Args: 1030 transform (list[float]): the transform for which the inverse is desired. 1031 1032 Returns: 1033 list[float]: shapely affine transform tuple that will invert the supplied 1034 transform. 1035 """ 1036 return as_shapely_transform( 1037 np.linalg.inv(as_numpy_matrix(transform))) 1038 1039 1040def as_shapely_transform(arr:np.array) -> list[float]: 1041 """Returns the shapely affine transform list equivalent to the supplied 1042 numpy matrix of a conventional augmented affine transform matrix. 1043 1044 Args: 1045 arr (np.array): augmented affine transform matrix of the desired 1046 transform. 1047 1048 Returns: 1049 list[float]: desired shapely affine transform list of floats. 1050 """ 1051 return [arr[0][0], arr[0][1], arr[1][0], arr[1][1], arr[0][2], arr[1][2]] 1052 1053 1054def as_numpy_matrix(transform:list[float]) -> np.array: 1055 """Converts the supplied shapely affine transform list to an augmented 1056 affine transform matrix in numpy array form. This makes combining transforms 1057 much easier. 1058 1059 Args: 1060 transform (list[float]): the transform in shapely format. 1061 1062 Returns: 1063 np.array: the transform in numpy matrix format. 1064 """ 1065 return np.array([[transform[0], transform[1], transform[4]], 1066 [transform[2], transform[3], transform[5]], 1067 [ 0, 0, 1]])
64def get_strand_ids(strands_spec: str) -> tuple[list[str]]: 65 """Conversts a strands specification string to a list of lists of strand 66 labels. 67 68 Args: 69 strands_spec (str): string format "a|bc|(de)f" | separates strands in 70 each direction and () designates combining labels into a single 71 strand that will be sliced lengthwise. Example output: 72 73 "a|bc|(de)f" -> (["a"], ["b", "c"], ["de", "f"]) 74 75 Superflous parentheses are removed, but no other error-checks are 76 applied. 77 78 Returns: 79 tuple[str]: tuple of lists of labels for each set of strands. 80 """ 81 strand_ids = [_parse_strand_label(s) for s in strands_spec.split("|")] 82 strand_ids = strand_ids if len(strand_ids) == 3 else strand_ids + [[""]] 83 return tuple(strand_ids)
Conversts a strands specification string to a list of lists of strand labels.
Arguments:
strands_spec (str): string format "a|bc|(de)f" | separates strands in each direction and () designates combining labels into a single strand that will be sliced lengthwise. Example output:
"a|bc|(de)f" -> (["a"], ["b", "c"], ["de", "f"])
- Superflous parentheses are removed, but no other error-checks are applied.
Returns:
tuple[str]: tuple of lists of labels for each set of strands.
86def centre_offset(shape: geom.Polygon, 87 target:tuple[float] = (0, 0)) -> tuple[float]: 88 """Returns vector required to move centroid of polygon to target. 89 90 Args: 91 shape (Polygon): polygon to move. 92 target (tuple[float], optional): target to move to. 93 Defaults to (0, 0). 94 95 Returns: 96 tuple[float]: tuple of x, y movement required. 97 """ 98 shape_c = shape.centroid 99 return (target[0] - shape_c.x, target[1] - shape_c.y)
Returns vector required to move centroid of polygon to target.
Arguments:
- shape (Polygon): polygon to move.
- target (tuple[float], optional): target to move to. Defaults to (0, 0).
Returns:
tuple[float]: tuple of x, y movement required.
102def get_regular_polygon(spacing, n:int) -> geom.Polygon: 103 """Returns regular polygon with n sides centered on (0, 0) with a horizontal base, and height given by spacing. 104 105 Args: 106 spacing (_type_): required height. 107 n (int): number of sides. 108 109 Returns: 110 geom.Polygon: required geom.Polygon. 111 """ 112 R = spacing / np.cos(np.radians(180 / n)) / 2 113 a0 = -90 + 180 / n 114 a_diff = 360 / n 115 angles = [a0 + a * a_diff for a in range(n)] 116 corners = [(R * np.cos(np.radians(a)), 117 R * np.sin(np.radians(a))) for a in angles] 118 return gridify(geom.Polygon(corners))
Returns regular polygon with n sides centered on (0, 0) with a horizontal base, and height given by spacing.
Arguments:
- spacing (_type_): required height.
- n (int): number of sides.
Returns:
geom.Polygon: required geom.Polygon.
121def offset_polygon_corners(polygon:geom.Polygon, 122 offset:int) -> geom.Polygon: 123 """Returns this polygon but with its first corner offset from its 124 original position in the coordinate sequence. The returned polygon will 125 be identical but stored differently internally. 126 127 Args: 128 polygon (geom.Polygon): the polygon to reorder. 129 offset (int): the number of corner positions by which to shift the 130 sequence. 131 132 Returns: 133 geom.Polygon: the reordered polygon. 134 """ 135 corners = get_corners(polygon, repeat_first = False) 136 return geom.Polygon([c for c in corners[offset:] + corners[:offset]])
Returns this polygon but with its first corner offset from its original position in the coordinate sequence. The returned polygon will be identical but stored differently internally.
Arguments:
- polygon (geom.Polygon): the polygon to reorder.
- offset (int): the number of corner positions by which to shift the sequence.
Returns:
geom.Polygon: the reordered polygon.
139def rotate_preserving_order(polygon:geom.Polygon, angle:float, 140 centre:geom.Point) -> geom.Polygon: 141 """Returns the supplied polygon rotated with the order of its corner points 142 preserved (not guaranteed by shapely.affinity.rotate). 143 144 Args: 145 polygon (geom.Polygon): polygon to rotate. 146 angle (float): desired angle of rotation (in degrees). 147 centre (geom.Point): the rotation centre (passed on to shapely.affinity. 148 rotate). 149 150 Returns: 151 geom.Polygon: rotated polygon. 152 """ 153 corners = get_corners(polygon, repeat_first = False) 154 return geom.Polygon([affine.rotate(c, angle, centre) for c in corners])
Returns the supplied polygon rotated with the order of its corner points preserved (not guaranteed by shapely.affinity.rotate).
Arguments:
- polygon (geom.Polygon): polygon to rotate.
- angle (float): desired angle of rotation (in degrees).
- centre (geom.Point): the rotation centre (passed on to shapely.affinity. rotate).
Returns:
geom.Polygon: rotated polygon.
165def is_convex(shape:geom.Polygon) -> bool: 166 """Tests for shape convexity. There are better ways to do this, like 167 e.g. using something like the get_interior_angles() function, but simply 168 checking if the area is close to that of its convex hull works too! 169 170 Args: 171 shape (geom.Polygon): polygon to check 172 173 Returns: 174 bool: True if the polygon is convex, False otherwise. 175 """ 176 return np.isclose(shape.area, shape.convex_hull.area, rtol = RESOLUTION)
Tests for shape convexity. There are better ways to do this, like e.g. using something like the get_interior_angles() function, but simply checking if the area is close to that of its convex hull works too!
Arguments:
- shape (geom.Polygon): polygon to check
Returns:
bool: True if the polygon is convex, False otherwise.
179def get_corners(shape:geom.Polygon, 180 repeat_first:bool = True) -> list[geom.Point]: 181 """Returns a list of geom.Points around the boundary of a polygon, optionally 182 repeating the first. Does no simplification (e.g. if a line segment has a 183 'corner' along its length, it is NOT removed; see get_clean_polygon for 184 that). Points have precision set to the package default tiling_utils. 185 RESOLUTION. 186 187 Args: 188 shape (geom.Polygon): polygon whose corners are required. 189 repeat_first (bool, optional): if True the first corner is repeated in the 190 returned list, if False it is omitted. Defaults to True. 191 192 Returns: 193 list[geom.Point]: list of geom.Point vertices of the polygon. 194 """ 195 corners = [gridify(geom.Point(pt)) for pt in shape.exterior.coords] 196 if repeat_first: 197 return corners 198 else: 199 return corners[:-1]
Returns a list of geom.Points around the boundary of a polygon, optionally repeating the first. Does no simplification (e.g. if a line segment has a 'corner' along its length, it is NOT removed; see get_clean_polygon for that). Points have precision set to the package default tiling_utils. RESOLUTION.
Arguments:
- shape (geom.Polygon): polygon whose corners are required.
- repeat_first (bool, optional): if True the first corner is repeated in the returned list, if False it is omitted. Defaults to True.
Returns:
list[geom.Point]: list of geom.Point vertices of the polygon.
202def get_sides(shape:geom.Polygon) -> list[geom.LineString]: 203 """Returns polygon sides as a list of geom.LineStrings, with resolution set 204 to the package default tiling_utils.RESOLUTION. No simplification for 205 successive colinear sides is carried out. 206 207 Args: 208 shape (geom.Polygon): polygon whose edges are required. 209 210 Returns: 211 list[geom.LineString]: list of geom.LineString sides of the polygon. 212 """ 213 corners = get_corners(shape) 214 return [gridify(geom.LineString([p1, p2])) 215 for p1, p2 in zip(corners[:-1], corners[1:])]
Returns polygon sides as a list of geom.LineStrings, with resolution set to the package default tiling_utils.RESOLUTION. No simplification for successive colinear sides is carried out.
Arguments:
- shape (geom.Polygon): polygon whose edges are required.
Returns:
list[geom.LineString]: list of geom.LineString sides of the polygon.
218def get_side_lengths(shape:geom.Polygon) -> list[float]: 219 """Returns list of lengths of polygon sides. No simplification for corners 220 along sides is carried out. 221 222 Args: 223 shape (geom.Polygon): polygon whose edge lengths are required. 224 225 Returns: 226 list[float]: list of side lengths. 227 """ 228 corners = get_corners(shape) 229 return [p1.distance(p2) for p1, p2 in zip(corners[:-1], corners[1:])]
Returns list of lengths of polygon sides. No simplification for corners along sides is carried out.
Arguments:
- shape (geom.Polygon): polygon whose edge lengths are required.
Returns:
list[float]: list of side lengths.
232def get_side_bearings(shape:geom.Polygon) -> tuple[tuple[float]]: 233 """Returns a list of angles (in degrees) between the sides of a polygon and 234 the positive x-axis, when proceeding from the first point in each side to its 235 end point. This should usually be CW around the polygon. 236 237 Args: 238 shape (geom.Polygon): polygon whose side bearings are required. 239 240 Returns: 241 tuple[tuple[float]]: tuple of bearings of each edge. 242 """ 243 return tuple([np.degrees(np.arctan2( 244 e.coords[1][1] - e.coords[0][1], e.coords[1][0] - e.coords[0][0])) 245 for e in get_sides(shape)])
Returns a list of angles (in degrees) between the sides of a polygon and the positive x-axis, when proceeding from the first point in each side to its end point. This should usually be CW around the polygon.
Arguments:
- shape (geom.Polygon): polygon whose side bearings are required.
Returns:
tuple[tuple[float]]: tuple of bearings of each edge.
248def get_interior_angles(shape:geom.Polygon) -> list[float]: 249 """Returns angles (in degrees) between successive edges of a polygon. No 250 polygon simplification is carried out so some angles may be 180 (i.e. a 251 'corner' along a side, such that successive sides are colinear). 252 253 Args: 254 shape (geom.Polygon): polygon whose angles are required. 255 256 Returns: 257 list[float]: list of angles. 258 """ 259 corners = get_corners(shape, repeat_first = False) 260 wrap_corners = corners[-1:] + corners + corners[:1] 261 triples = zip(wrap_corners[:-2], wrap_corners[1:-1], wrap_corners[2:]) 262 return [get_inner_angle(p1, p2, p3) for p1, p2, p3 in triples]
Returns angles (in degrees) between successive edges of a polygon. No polygon simplification is carried out so some angles may be 180 (i.e. a 'corner' along a side, such that successive sides are colinear).
Arguments:
- shape (geom.Polygon): polygon whose angles are required.
Returns:
list[float]: list of angles.
265def get_clean_polygon( 266 shape:Union[geom.MultiPolygon,geom.Polygon]) -> geom.Polygon: 267 """Returns polygon with any successive corners that are co-linear along a 268 side or very close to one another removed. 269 270 Particularly useful for tidying polygons weave tilings that have been 271 assembled from multiple 'cells' in the weave grid. 272 273 Args: 274 shape (Union[geom.MultiPolygon,geom.Polygon]): polygon to clean. 275 276 Returns: 277 geom.Polygon: cleaned polygon. 278 """ 279 if isinstance(shape, geom.MultiPolygon): 280 return geom.MultiPolygon([get_clean_polygon(p) for p in shape.geoms]) 281 else: 282 corners = get_corners(shape, repeat_first = False) 283 # first remove any 'near neighbour' corners 284 distances = get_side_lengths(shape) 285 to_remove = [np.isclose(d, 0, rtol = RESOLUTION, atol = 10 * RESOLUTION) 286 for d in distances] 287 corners = [c for c, r in zip(corners, to_remove) if not r] 288 # next remove any that are colinear 289 p = geom.Polygon(corners) 290 corners = get_corners(p, repeat_first = False) 291 angles = get_interior_angles(p) 292 to_remove = [np.isclose(a, 0, rtol = RESOLUTION, atol = RESOLUTION) or 293 np.isclose(a, 180, rtol = RESOLUTION, atol = RESOLUTION) 294 for a in angles] 295 corners = [c for c, r in zip(corners, to_remove) if not r] 296 return gridify(geom.Polygon(corners))
Returns polygon with any successive corners that are co-linear along a side or very close to one another removed.
Particularly useful for tidying polygons weave tilings that have been assembled from multiple 'cells' in the weave grid.
Arguments:
- shape (Union[geom.MultiPolygon,geom.Polygon]): polygon to clean.
Returns:
geom.Polygon: cleaned polygon.
299def get_inner_angle(p1:geom.Point, p2:geom.Point, p3:geom.Point) -> float: 300 r"""Returns the angle (in degrees) between line p1-p2 and p2-p3, i.e., the 301 angle A below 302 303 p2 304 / \ 305 / A \ 306 p1 p3 307 308 Args: 309 p1 (geom.Point): first point. 310 p2 (geom.Point): second 'corner' point. 311 p3 (geom.Point): third point. 312 313 Returns: 314 float: angle in degrees. 315 """ 316 return np.degrees(np.arctan2(p3.y - p2.y, p3.x - p2.x) - 317 np.arctan2(p1.y - p2.y, p1.x - p2.x)) % 360
Returns the angle (in degrees) between line p1-p2 and p2-p3, i.e., the angle A below
p2
/ \
/ A \
p1 p3
Arguments:
- p1 (geom.Point): first point.
- p2 (geom.Point): second 'corner' point.
- p3 (geom.Point): third point.
Returns:
float: angle in degrees.
320def get_outer_angle(p1:geom.Point, p2:geom.Point, p3:geom.Point) -> float: 321 r"""Returns outer angle (in degrees) between lines p1-p2 and p2-p3, i.e., the 322 angle A below 323 324 / 325 / 326 p2 A 327 / \ 328 / \ 329 p1 p3 330 331 Args: 332 p1 (geom.Point): first point. 333 p2 (geom.Point): second 'corner' point. 334 p3 (geom.Point): third point. 335 336 Returns: 337 float: angle in degrees. 338 """ 339 return 180 - get_inner_angle(p1, p2, p3)
Returns outer angle (in degrees) between lines p1-p2 and p2-p3, i.e., the angle A below
/
/
p2 A
/ \
/ \
p1 p3
Arguments:
- p1 (geom.Point): first point.
- p2 (geom.Point): second 'corner' point.
- p3 (geom.Point): third point.
Returns:
float: angle in degrees.
342def is_regular_polygon(shape:geom.Polygon) -> bool: 343 """Tests if supplied polygon is regular (i.e. equal sides and angles). 344 345 Args: 346 shape (geom.Polygon): polygon to test. 347 348 Returns: 349 bool: True if polygon is regular, False if not. 350 """ 351 side_lengths = get_side_lengths(shape) 352 angles = get_interior_angles(shape) 353 return all(np.isclose(side_lengths, side_lengths[0])) \ 354 and all(np.isclose(angles, angles[0]))
Tests if supplied polygon is regular (i.e. equal sides and angles).
Arguments:
- shape (geom.Polygon): polygon to test.
Returns:
bool: True if polygon is regular, False if not.
357def is_tangential(shape:geom.Polygon) -> bool: 358 """Determines if the supplied polygon is tangential i.e., it can have 359 circle inscribed tangential to all its sides. 360 361 Note that this will fail for polygons with successive colinear sides, 362 meaning that polygons should be fully simplified... 363 """ 364 if is_regular_polygon(shape): 365 return True 366 side_lengths = get_side_lengths(shape) 367 n = len(side_lengths) 368 if n % 2 == 1: 369 if n == 3: #triangles are easy! 370 return True 371 # odd number of sides there is a nice solvable system of equations 372 # see https://math.stackexchange.com/questions/4065370/tangential-polygons-conditions-on-edge-lengths 373 mat = np.identity(n) + np.roll(np.identity(n), 1, axis = 1) 374 fractions = np.linalg.inv(mat) @ side_lengths / side_lengths 375 if not(np.isclose(np.mean(fractions), 376 0.5, rtol= RESOLUTION, atol = RESOLUTION)): 377 return False 378 ones = [np.isclose(f, 1, rtol = RESOLUTION, atol = RESOLUTION) 379 for f in fractions] 380 zeros = [np.isclose(f, 0, rtol = RESOLUTION, atol = RESOLUTION) 381 for f in fractions] 382 negatives = [f < 0 for f in fractions] 383 return not (any(ones) or any(zeros) or any(negatives)) 384 elif n == 4: 385 # for quadrilaterals odd and even side lengths are equal 386 return np.isclose(sum(side_lengths[0::2]), 387 sum(side_lengths[1::2]), rtol = RESOLUTION) 388 else: 389 # other even numbers of sides... hard to avoid brute force 390 bisectors = [get_angle_bisector(shape, i) for i in range(n)] 391 intersects = [b1.intersection(b2) for b1, b2 in 392 zip(bisectors, bisectors[1:] + bisectors[:1]) 393 if b1.intersects(b2)] 394 distances = [i1.distance(i2) for i1, i2 in 395 zip(intersects, intersects[1:] + intersects[:1])] 396 return all([d <= 2 * RESOLUTION for d in distances])
Determines if the supplied polygon is tangential i.e., it can have circle inscribed tangential to all its sides.
Note that this will fail for polygons with successive colinear sides, meaning that polygons should be fully simplified...
399def incentre(shape:geom.Polygon) -> geom.Point: 400 """A different polygon centre, which produces better results for some 401 dual tilings where tiles are not regular polygons... see 402 https://en.wikipedia.org/wiki/Incenter 403 404 This method relies on the polygon being tangential, i.e. there is an 405 inscribed circle to which all sides of the polygon are tangent. It will 406 work on all the polygons encountered in the Laves tilings, but is not 407 guaranteed to work on all polygons. 408 409 Given that the polygon is tangential, the radius of the inscribed circle is 410 the [apothem of the polygon](https://en.wikipedia.org/wiki/Apothem) given 411 by 2 x Area / Perimeter. We apply a parallel offset of this size to two 412 sides of the polygon and find their intersection to determine the centre of 413 the circle, givng the incentre of the polygon. 414 415 Args: 416 shape (geom.Polygon): the polygon. 417 418 Returns: 419 geom.Point: the incentre of the polygon. 420 """ 421 if is_regular_polygon(shape): 422 return shape.centroid 423 shape = ensure_cw(shape) 424 if is_convex(shape): 425 if is_tangential(shape): # find the incentre 426 corners = get_corners(shape, repeat_first = False) 427 r = get_apothem_length(shape) 428 e1 = geom.LineString(corners[:2]).parallel_offset(r, side = "right") 429 e2 = geom.LineString(corners[1:3]).parallel_offset(r, side = "right") 430 return e1.intersection(e2) # TODO: add exception if no intersection 431 return shape.centroid
A different polygon centre, which produces better results for some dual tilings where tiles are not regular polygons... see https://en.wikipedia.org/wiki/Incenter
This method relies on the polygon being tangential, i.e. there is an inscribed circle to which all sides of the polygon are tangent. It will work on all the polygons encountered in the Laves tilings, but is not guaranteed to work on all polygons.
Given that the polygon is tangential, the radius of the inscribed circle is the apothem of the polygon given by 2 x Area / Perimeter. We apply a parallel offset of this size to two sides of the polygon and find their intersection to determine the centre of the circle, givng the incentre of the polygon.
Args: shape (geom.Polygon): the polygon.
Returns:
geom.Point: the incentre of the polygon.
438def ensure_cw(shape:geom.Polygon) -> geom.Polygon: 439 """Returns the polygon with its outer boundary vertices in clockwise order. 440 441 It is important to note that shapely.set_precision() imposes clockwise order 442 on polygons, and since it is used widely throughout theses modules, it makes 443 sense to impose this order. 444 445 Args: 446 shape (geom.Polygon): the polygon. 447 448 Returns: 449 geom.Polygon: the polygon in clockwise order. 450 """ 451 if geom.LinearRing(shape.exterior.coords).is_ccw: 452 return shape.reverse() 453 else: 454 return shape
Returns the polygon with its outer boundary vertices in clockwise order.
It is important to note that shapely.set_precision() imposes clockwise order on polygons, and since it is used widely throughout theses modules, it makes sense to impose this order.
Args: shape (geom.Polygon): the polygon.
Returns:
geom.Polygon: the polygon in clockwise order.
457def get_angle_bisector(shape:geom.Polygon, v = 0) -> geom.LineString: 458 """Returns a line which is the angle bisector of the specified corner of the 459 supplied polygon. 460 461 Args: 462 shape (geom.Polygon): the polygon 463 v (int, optional): index of the corner whose bisector is required. 464 Defaults to 0. 465 466 Returns: 467 geom.LineString: line which bisects the specified corner. 468 """ 469 pts = get_corners(shape, repeat_first = False) 470 n = len(pts) 471 p0 = pts[v % n] 472 p1 = pts[(v + 1) % n] 473 p2 = pts[(v - 1) % n] 474 d01 = p0.distance(p1) 475 d02 = p0.distance(p2) 476 if d01 < d02: 477 p2 = geom.LineString([p0, p2]).interpolate(d01) 478 else: 479 p1 = geom.LineString([p0, p1]).interpolate(d02) 480 m = geom.Point((p1.x + p2.x) / 2, (p1.y + p2.y) / 2) 481 # we have to scale this to much larger in case of an angle near 180 482 return affine.scale(geom.LineString([p0, m]), 10, 10, origin = p0)
Returns a line which is the angle bisector of the specified corner of the supplied polygon.
Arguments:
- shape (geom.Polygon): the polygon
- v (int, optional): index of the corner whose bisector is required.
- Defaults to 0.
Returns:
geom.LineString: line which bisects the specified corner.
509def gridify( 510 gs:Union[gpd.GeoSeries, gpd.GeoDataFrame, geom.Polygon] 511 ) -> Union[gpd.GeoSeries, gpd.GeoDataFrame, geom.Polygon]: 512 """Returns the supplied GeoSeries rounded to the specified precision. 513 514 Args: 515 gs (gpd.GeoSeries): geometries to gridify. 516 precision (int, optional): digits of precision. Defaults to 6. 517 518 Returns: 519 gpd.GeoSeries: the rounded geometries. 520 """ 521 # return gpd.GeoSeries( 522 # list(gs.apply( 523 # wkt.dumps, rounding_precision = precision).apply(wkt.loads))) 524 if isinstance(gs, (geom.Polygon, geom.Point, geom.MultiPoint, 525 geom.MultiPolygon, geom.LineString)) : 526 return shapely.set_precision(gs, grid_size = RESOLUTION) 527 if isinstance(gs, gpd.GeoSeries): 528 return gpd.GeoSeries( 529 [shapely.set_precision(g, grid_size = RESOLUTION) for g in list(gs)], 530 crs = gs.crs) 531 if isinstance(gs, gpd.GeoDataFrame): 532 gs.geometry = gridify(gs.geometry) 533 return gs
Returns the supplied GeoSeries rounded to the specified precision.
Arguments:
- gs (gpd.GeoSeries): geometries to gridify.
- precision (int, optional): digits of precision. Defaults to 6.
Returns:
gpd.GeoSeries: the rounded geometries.
536def get_dual_tile_unit(unit: TileUnit) -> gpd.GeoDataFrame: 537 """Converts supplied TileUnit to a candidate GeoDataFrame of its dual 538 TileUnit. 539 540 NOTE: this is complicated and not remotely guaranteed to work! 541 a particular issue is that where to place the vertices of the faces 542 of the dual with respect to the tiles in the original is ill-defined. 543 This is because the dual process is topologically not metrically defined, 544 so that exact vertex locations are ambiguous. Tiling duality is defined in 545 Section 4.2 of Grunbaum B, Shephard G C, 1987 _Tilings and Patterns_ (W. H. 546 Freeman and Company, New York) 547 548 NOTE: In general, this method will work only if all supplied tiles are 549 regular polygons. A known exception is if the only non-regular polygons are 550 triangles. 551 552 NOTE: 'clean' polygons are required. If supplied polygons have messy 553 vertices with multiple points where there is only one proper point, bad 554 things are likely to happen! Consider using `clean_polygon()` on the 555 tile geometries. 556 557 Because of the above limitations, we only return a GeoDataFrame 558 for inspection. However some `weavingspace.tile_unit.TileUnit` setup 559 methods in `weavingspace.tiling_geometries` use this method, where we are 560 confident the returned dual is valid. 561 562 Args: 563 unit (TileUnit): the tiling for which the dual is required. 564 565 Returns: 566 gpd.GeoDataFrame: GeoDataFrame that could be the tiles attribute for 567 a TileUnit of the dual tiling. 568 """ 569 # get a local patch of this Tiling 570 local_patch = unit.get_local_patch(r = 1, include_0 = True) 571 # Find the interior points of these tiles - these will be guaranteed 572 # to have a sequence of surrounding tiles incident on them 573 interior_pts = _get_interior_vertices(local_patch) 574 # Compile a list of the polygons incident on the interior points 575 cycles = [] 576 for pt in interior_pts: 577 cycles.append( 578 set([poly_id for poly_id, p in enumerate(local_patch.geometry) 579 if pt.distance(p) < RESOLUTION * 2])) 580 # convert the polygon ID sequences to (centroid.x, centroid.y, ID) tuples 581 dual_faces = [] 582 for cycle in cycles: 583 ids, pts = [], [] 584 for poly_id in cycle: 585 ids.append(local_patch.tile_id[poly_id]) 586 poly = local_patch.geometry[poly_id] 587 pts.append(incentre(poly)) 588 # sort them into CW order so they are well formed 589 sorted_coords = sort_cw([(pt.x, pt.y, id) 590 for pt, id in zip(pts, ids)]) 591 dual_faces.append( 592 (geom.Polygon([(pt_id[0], pt_id[1]) for pt_id in sorted_coords]), 593 "".join([pt_id[2] for pt_id in sorted_coords]))) 594 # ensure the resulting face centroids are inside the original tile 595 # displaced a little to avoid uncertainties at corners/edges 596 # TODO: Check the logic of this - it seems like dumb luck that it works... 597 dual_faces = [(f, id) for f, id in dual_faces 598 if affine.translate( 599 unit.prototile.geometry[0], RESOLUTION * 10, 600 RESOLUTION * 10).contains(f.centroid)] 601 gdf = gpd.GeoDataFrame( 602 data = {"tile_id": [f[1] for f in dual_faces]}, crs = unit.crs, 603 geometry = gridify(gpd.GeoSeries([f[0] for f in dual_faces]))) 604 # ensure no duplicates 605 gdf = gdf.dissolve(by = "tile_id", as_index = False).explode( 606 index_parts = False, ignore_index = True) 607 608 gdf.tile_id = relabel(gdf.tile_id) 609 return gdf
Converts supplied TileUnit to a candidate GeoDataFrame of its dual TileUnit.
NOTE: this is complicated and not remotely guaranteed to work! a particular issue is that where to place the vertices of the faces of the dual with respect to the tiles in the original is ill-defined. This is because the dual process is topologically not metrically defined, so that exact vertex locations are ambiguous. Tiling duality is defined in Section 4.2 of Grunbaum B, Shephard G C, 1987 _Tilings and Patterns_ (W. H. Freeman and Company, New York)
NOTE: In general, this method will work only if all supplied tiles are regular polygons. A known exception is if the only non-regular polygons are triangles.
NOTE: 'clean' polygons are required. If supplied polygons have messy
vertices with multiple points where there is only one proper point, bad
things are likely to happen! Consider using clean_polygon()
on the
tile geometries.
Because of the above limitations, we only return a GeoDataFrame
for inspection. However some weavingspace.tile_unit.TileUnit
setup
methods in weavingspace.tiling_geometries
use this method, where we are
confident the returned dual is valid.
Arguments:
- unit (TileUnit): the tiling for which the dual is required.
Returns:
gpd.GeoDataFrame: GeoDataFrame that could be the tiles attribute for a TileUnit of the dual tiling.
612def relabel(data:Iterable) -> list: 613 """Returns supplied data reassigned with unique values from 614 string.ascii_letters. 615 616 Args: 617 data (Iterable): the data to relabel 618 619 Returns: 620 list: the reassigned data 621 """ 622 new_data = {} 623 d_count = 0 624 for d in data: 625 if not d in new_data: 626 new_data[d] = string.ascii_letters[d_count] 627 d_count = d_count + 1 628 return [new_data[d] for d in data]
Returns supplied data reassigned with unique values from string.ascii_letters.
Arguments:
- data (Iterable): the data to relabel
Returns:
list: the reassigned data
631def sort_cw(pts_ids:list[tuple[float, float, str]]): 632 """Sorts supplied tuple of x, y, ID into clockwise order relative to their 633 mean centre. 634 635 Args: 636 pts_ids (list[tuple[float, float, str]]): A tuple of a pair of 637 floats and a string. 638 639 Returns: 640 list: a list in the same format as supplied sorted into 641 clockwise order of the point locations. 642 """ 643 x = [p[0] for p in pts_ids] 644 y = [p[1] for p in pts_ids] 645 cx, cy = np.mean(x), np.mean(y) 646 dx = [_ - cx for _ in x] 647 dy = [_ - cy for _ in y] 648 angles = [np.arctan2(dy, dx) for dx, dy in zip(dx, dy)] 649 d = dict(zip(angles, pts_ids)) 650 return [pt_id for angle, pt_id in reversed(sorted(d.items()))]
Sorts supplied tuple of x, y, ID into clockwise order relative to their mean centre.
Arguments:
- pts_ids (list[tuple[float, float, str]]): A tuple of a pair of floats and a string.
Returns:
list: a list in the same format as supplied sorted into clockwise order of the point locations.
653def order_of_pts_cw_around_centre(pts:list[geom.Point], centre:geom.Point): 654 """Returns the order of the supplied points clockwise relative to supplied 655 centre point, i.e. a list of the indices in clockwise order. 656 657 Args: 658 pts (list[geom.Point]): list of points to order. 659 centre (geom.Point): centre relative to which CW order is determined. 660 661 Returns: 662 _type_: list of reordered points. 663 """ 664 dx = [p.x - centre.x for p in pts] 665 dy = [p.y - centre.y for p in pts] 666 angles = [np.arctan2(dy, dx) for dx, dy in zip(dx, dy)] 667 d = dict(zip(angles, range(len(pts)))) 668 return [i for angle, i in reversed(sorted(d.items()))]
Returns the order of the supplied points clockwise relative to supplied centre point, i.e. a list of the indices in clockwise order.
Arguments:
- pts (list[geom.Point]): list of points to order.
- centre (geom.Point): centre relative to which CW order is determined.
Returns:
_type_: list of reordered points.
671def write_map_to_layers(gdf:gpd.GeoDataFrame, fname:str = "output.gpkg", 672 tile_var:str = "tile_id") -> None: 673 """Writes supplied GeoDataFrame to a GPKG file with layers based on 674 the tile_var attribute. 675 676 Args: 677 gdf (gpd.GeoDataFrame): the GeoDataFrame. 678 fname (str, optional): filename to write. 679 tile_var (str, optional): the attribute to use to separate 680 output file into layers. Defaults to "tile_id". 681 """ 682 grouped = gdf.groupby(tile_var, as_index = False) 683 for e in pd.Series.unique(gdf[tile_var]): 684 grouped.get_group(e).to_file(fname, layer = e, driver = "GPKG")
Writes supplied GeoDataFrame to a GPKG file with layers based on the tile_var attribute.
Arguments:
- gdf (gpd.GeoDataFrame): the GeoDataFrame.
- fname (str, optional): filename to write.
- tile_var (str, optional): the attribute to use to separate output file into layers. Defaults to "tile_id".
687def get_collapse_distance(geometry:geom.Polygon) -> float: 688 """Returns the distance under which the supplied polygon will shrink 689 to nothing if negatively buffered by that distance. 690 691 Performs a binary search between an upper bound based on the radius of 692 the circle of equal area to the polygon, and 0. 693 694 Args: 695 geometry (geom.Polygon): the polygon. 696 697 Returns: 698 float: its collapse distance. 699 """ 700 if is_convex(geometry): 701 return get_apothem_length(geometry) 702 radius = np.sqrt(geometry.area / np.pi) 703 lower = 0 704 upper = radius 705 delta = 1e12 706 stop_delta = radius / 1000 707 while delta > stop_delta: 708 new_r = (lower + upper) / 2 709 if geometry.buffer(-new_r).area > 0: 710 lower = new_r 711 delta = upper - new_r 712 else: 713 upper = new_r 714 delta = new_r - lower 715 return new_r
Returns the distance under which the supplied polygon will shrink to nothing if negatively buffered by that distance.
Performs a binary search between an upper bound based on the radius of the circle of equal area to the polygon, and 0.
Arguments:
- geometry (geom.Polygon): the polygon.
Returns:
float: its collapse distance.
718def get_largest_polygon(polygons:gpd.GeoSeries) -> gpd.GeoSeries: 719 """Returns the largest polygon in a GeoSeries as a GeoSeries of one polygon. 720 721 Args: 722 polygons (gpd.GeoSeries): the set of polygons to pick from. 723 724 Returns: 725 gpd.GeoSeries: the largest polygon. 726 """ 727 actual_polygons = [p for p in polygons.geometry 728 if isinstance(p, (geom.Polygon, geom.MultiPolygon))] 729 areas = [p.area for p in actual_polygons] 730 max_area = max(areas) 731 return gpd.GeoSeries([actual_polygons[areas.index(max_area)]])
Returns the largest polygon in a GeoSeries as a GeoSeries of one polygon.
Arguments:
- polygons (gpd.GeoSeries): the set of polygons to pick from.
Returns:
gpd.GeoSeries: the largest polygon.
734def touch_along_an_edge(p1:geom.Polygon, p2:geom.Polygon) -> bool: 735 """Tests if two polygons touch along an edge. 736 737 Checks that the intersection area of the two polygons buffered by 738 a small amount is large enough to indicate that they neighbour at more 739 than a corner. 740 741 Args: 742 p1 (geom.Polygon): First polygon 743 p2 (geom.Polygon): Second polygon 744 745 Returns: 746 bool: True if they neighbour along an edge 747 """ 748 return p1.buffer(RESOLUTION, join_style = 2, cap_style = 3) \ 749 .intersection(p2.buffer(RESOLUTION, join_style = 2, cap_style = 3)) \ 750 .area > 16 * RESOLUTION ** 2
Tests if two polygons touch along an edge.
Checks that the intersection area of the two polygons buffered by a small amount is large enough to indicate that they neighbour at more than a corner.
Arguments:
- p1 (geom.Polygon): First polygon
- p2 (geom.Polygon): Second polygon
Returns:
bool: True if they neighbour along an edge
753def get_width_height_left_bottom(gs:gpd.GeoSeries) -> tuple[float]: 754 """Returns width, height, left and bottom limits of a GeoSeries 755 756 Args: 757 gs (geopandas.GeoSeries): GeoSeries for which limits are required. 758 759 Returns: 760 tuple: four float values of width, height, left and bottom of gs. 761 """ 762 extent = gs.total_bounds 763 return (extent[2] - extent[0], extent[3] - extent[1], 764 extent[0], extent[1])
Returns width, height, left and bottom limits of a GeoSeries
Arguments:
- gs (geopandas.GeoSeries): GeoSeries for which limits are required.
Returns:
tuple: four float values of width, height, left and bottom of gs.
767def get_bounding_ellipse(shapes:gpd.GeoSeries, 768 mag:float = 1.0) -> gpd.GeoSeries: 769 """Returns an ellipse containing the supplied shapes. 770 771 The method used is to calculate the size of square that would contain 772 the shapes, if they had an aspect ratio 1, then stretch the circle in 773 the x, y directions according to the actual aspect ratio of the shapes. 774 775 Args: 776 shapes (gpd.GeoSeries): the shapes to be contained. 777 mag (float, optional): optionally increase the size of the returned 778 ellipse by this scale factor. Defaults to 1.0. 779 780 Returns: 781 gpd.GeoSeries: the set of shapes. 782 """ 783 784 w, h, l, b = get_width_height_left_bottom(shapes) 785 786 c = geom.Point(l + w / 2, b + h / 2) 787 r = min(w, h) * np.sqrt(2) 788 circle = [c.buffer(r)] 789 return gridify(gpd.GeoSeries(circle, crs = shapes.crs).scale( 790 w / r * mag / np.sqrt(2), h / r * mag / np.sqrt(2), origin = c))
Returns an ellipse containing the supplied shapes.
The method used is to calculate the size of square that would contain the shapes, if they had an aspect ratio 1, then stretch the circle in the x, y directions according to the actual aspect ratio of the shapes.
Arguments:
- shapes (gpd.GeoSeries): the shapes to be contained.
- mag (float, optional): optionally increase the size of the returned ellipse by this scale factor. Defaults to 1.0.
Returns:
gpd.GeoSeries: the set of shapes.
793def get_tiling_edges(tiles:gpd.GeoSeries) -> gpd.GeoSeries: 794 """Returns linestring GeoSeries from supplied polygon GeoSeries. 795 796 This is used to allow display of edges of tiles in legend when they are 797 masked by an ellipse (if we instead clip polygons then the ellipse edge 798 will also show in the result.) 799 800 Args: 801 shapes (gpd.GeoSeries): Polygons to convert. 802 803 Returns: 804 gpd.GeoSeries: LineStrings from the supplied Polygons. 805 """ 806 tiles = tiles.explode(ignore_index = True) 807 edges = [geom.LineString(p.exterior.coords) for p in tiles] 808 return gpd.GeoSeries(edges, crs = tiles.crs)
Returns linestring GeoSeries from supplied polygon GeoSeries.
This is used to allow display of edges of tiles in legend when they are masked by an ellipse (if we instead clip polygons then the ellipse edge will also show in the result.)
Arguments:
- shapes (gpd.GeoSeries): Polygons to convert.
Returns:
gpd.GeoSeries: LineStrings from the supplied Polygons.
811def get_polygon_sector(polygon:geom.Polygon, start:float = 0.0, 812 end:float = 1.0) -> geom.Polygon: 813 """Returns a sector of the provided Polygon. 814 815 The returned sector is a section of the polygon boundary between the 816 normalized start and end positions, and including the polygon centroid. 817 Should (probably) only be applied to convex polygons. 818 819 Args: 820 shape (geom.Polygon): the Polygon. 821 start (float): normalized start position along the boundary. Defaults to 822 0. 823 end (float): normalized start position along the boundary. Defaults to 824 1. 825 826 Returns: 827 geom.Polygon: the requested polygon sector. 828 """ 829 if start == end: 830 # must return a null polygon since the calling context 831 # expects to get something back... which most likely 832 # is needed to align with other data 833 return geom.Polygon() 834 if start * end < 0: 835 # either side of 0/1 so assume required sector includes 0 836 e1, e2 = min(start, end), max(start, end) 837 arc1 = shapely.ops.substring(geom.LineString(polygon.exterior.coords), 838 np.mod(e1, 1), 1, normalized = True) 839 arc2 = shapely.ops.substring(geom.LineString(polygon.exterior.coords), 840 0, e2, normalized = True) 841 sector = geom.Polygon([polygon.centroid] + 842 list(arc1.coords) + 843 list(arc2.coords)[1:]) 844 else: 845 arc = shapely.ops.substring(geom.LineString(polygon.exterior.coords), 846 start, end, normalized = True) 847 sector = geom.Polygon([polygon.centroid] + list(arc.coords)) 848 return gridify(sector)
Returns a sector of the provided Polygon.
The returned sector is a section of the polygon boundary between the normalized start and end positions, and including the polygon centroid. Should (probably) only be applied to convex polygons.
Arguments:
- shape (geom.Polygon): the Polygon.
- start (float): normalized start position along the boundary. Defaults to 0.
- end (float): normalized start position along the boundary. Defaults to 1.
Returns:
geom.Polygon: the requested polygon sector.
851def repair_polygon( 852 polygon:Union[geom.Polygon, geom.MultiPolygon, gpd.GeoSeries], 853 shrink_then_grow:bool = True) -> Union[geom.Polygon, gpd.GeoSeries]: 854 """Convenience function to 'repair' a shapely polyon or GeoSeries by applying 855 a negative buffer then the same positive buffer. 856 857 Optionally the buffer may be applied in the opposite order (i.e. grow then 858 shrink). This operation may also convert a MultiPolygon that has some 'stray' 859 parts to a Polygon. 860 861 This is method is often unofficially recommended (on stackexchange etc.) 862 even in the shapely docs, to resolve topology issues and extraneous 863 additional vertices appearing when spatial operations are repeatedly 864 applied. 865 866 Args: 867 p (Union[geom.Polygon, gpd.GeoSeries]): Polygon or GeoSeries to clean. 868 res (float, optional): buffer size to use. Defaults to 1e-3. 869 shrink_then_grow (bool, optional): if True the negative buffer is 870 applied first, otherwise the buffer operations are applied in 871 reverse. Defaults to True. 872 873 Returns: 874 Union[geom.Polygon, gpd.GeoSeries]: the cleaned Polygon or GeoSeries. 875 """ 876 if shrink_then_grow: 877 return polygon.buffer( 878 -RESOLUTION * 10, join_style = 2, cap_style = 3).buffer( 879 RESOLUTION * 10, join_style = 2, cap_style = 3) 880 else: 881 return polygon.buffer( 882 RESOLUTION * 10, join_style = 2, cap_style = 3).buffer( 883 -RESOLUTION * 10, join_style = 2, cap_style = 3)
Convenience function to 'repair' a shapely polyon or GeoSeries by applying a negative buffer then the same positive buffer.
Optionally the buffer may be applied in the opposite order (i.e. grow then shrink). This operation may also convert a MultiPolygon that has some 'stray' parts to a Polygon.
This is method is often unofficially recommended (on stackexchange etc.) even in the shapely docs, to resolve topology issues and extraneous additional vertices appearing when spatial operations are repeatedly applied.
Arguments:
- p (Union[geom.Polygon, gpd.GeoSeries]): Polygon or GeoSeries to clean.
- res (float, optional): buffer size to use. Defaults to 1e-3.
- shrink_then_grow (bool, optional): if True the negative buffer is applied first, otherwise the buffer operations are applied in reverse. Defaults to True.
Returns:
Union[geom.Polygon, gpd.GeoSeries]: the cleaned Polygon or GeoSeries.
886def safe_union(gs:gpd.GeoSeries, 887 as_polygon:bool = False) -> Union[gpd.GeoSeries, geom.Polygon]: 888 """Unions the supplied GeoSeries of Polygons while buffering them to avoid 889 gaps and odd internal floating edges. Optionally returns a Polygon or a 890 GeoSeries. 891 892 Frequently when unioning polygons that are ostensibly adjacent 'rogue' 893 internal boundaries remain in the result. We can avoid this by buffering the 894 polygons before unioning them, then reversing the buffer on the unioned 895 shape. 896 897 Args: 898 gs (gpd.GeoSeries): the Polygons to union. 899 res (float, optional): size of the buffer to use. Defaults to 1e-3. 900 as_polygon (bool, optional): if True returns a Polygon, otherwise 901 returns a one Polygon GeoSeries. Defaults to False. 902 903 Returns: 904 Union[gpd.GeoSeries, geom.Polygon]: the resulting union of supplied 905 polygons. 906 """ 907 union = gs.buffer(RESOLUTION * 10, join_style = 2, cap_style = 3) \ 908 .unary_union.buffer(-RESOLUTION * 10, join_style = 2, cap_style = 3) 909 if as_polygon: 910 return gridify(union) 911 else: 912 return gridify(gpd.GeoSeries([union], crs = gs.crs))
Unions the supplied GeoSeries of Polygons while buffering them to avoid gaps and odd internal floating edges. Optionally returns a Polygon or a GeoSeries.
Frequently when unioning polygons that are ostensibly adjacent 'rogue' internal boundaries remain in the result. We can avoid this by buffering the polygons before unioning them, then reversing the buffer on the unioned shape.
Arguments:
- gs (gpd.GeoSeries): the Polygons to union.
- res (float, optional): size of the buffer to use. Defaults to 1e-3.
- as_polygon (bool, optional): if True returns a Polygon, otherwise returns a one Polygon GeoSeries. Defaults to False.
Returns:
Union[gpd.GeoSeries, geom.Polygon]: the resulting union of supplied polygons.
915def zigzag_between_points( 916 p0:geom.Point, p1: geom.Point, n:int, h:float = 1.0, smoothness: int = 0): 917 918 template_steps = n * 2 + 1 919 r = p0.distance(p1) 920 921 x = np.linspace(0, n * np.pi, template_steps, endpoint = True) 922 y = [np.sin(x) for x in x] 923 s = interpolate.InterpolatedUnivariateSpline(x, y, k = 2) 924 925 spline_steps = (n + smoothness) * 2 + 1 926 xs = np.linspace(0, n * np.pi, spline_steps, endpoint = True) 927 ys = s(xs) 928 929 sfx = 1 / max(x) * r 930 sfy = h * r / 2 931 theta = np.arctan2(p1.y - p0.y, p1.x - p0.x) 932 933 ls = geom.LineString([geom.Point(x, y) for x, y in zip(xs, ys)]) 934 ls = affine.translate(ls, 0, -(ls.bounds[1] + ls.bounds[3]) / 2) 935 ls = affine.scale(ls, xfact = sfx, yfact = sfy, origin = (0, 0)) 936 ls = affine.rotate(ls, theta, (0, 0), use_radians = True) 937 x0, y0 = list(ls.coords)[0] 938 return affine.translate(ls, p0.x - x0, p0.y - y0)
941def get_translation_transform(dx:float, dy:float) -> list[float]: 942 """Returns the shapely affine transform tuple for a translation. 943 944 Args: 945 dx (float): translation distance in x direction. 946 dy (float): translation distance in y direction. 947 948 Returns: 949 list[float]: a six item list of floats, per the shapely.affinity. 950 affine_transform method, see 951 https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations 952 """ 953 return [1, 0, 0, 1, dx, dy]
Returns the shapely affine transform tuple for a translation.
Arguments:
- dx (float): translation distance in x direction.
- dy (float): translation distance in y direction.
Returns:
list[float]: a six item list of floats, per the shapely.affinity. affine_transform method, see https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
956def get_rotation_transform( 957 angle:float, centre:tuple[float] = None) -> list[float]: 958 """Returns the shapely affine transform tuple for a rotation, optionally 959 about a supplied centre point. 960 961 Args: 962 angle (float): the angle of rotation (in degrees). 963 centre (tuple[float], optional): An option centre location. Defaults to 964 None, which will in turn be converted to (0, 0). 965 966 Returns: 967 list[float]: a six item list of floats, per the shapely.affinity. 968 affine_transform method, see 969 https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations 970 """ 971 if centre is None or np.allclose((0, 0), centre, atol = RESOLUTION): 972 a = np.radians(angle) 973 return [np.cos(a), -np.sin(a), np.sin(a), np.cos(a), 0, 0] 974 975 dx, dy = centre 976 t1 = get_translation_transform(-dx, -dy) 977 r = get_rotation_transform(angle) 978 t2 = get_translation_transform(dx, dy) 979 return combine_transforms([t1, r, t2])
Returns the shapely affine transform tuple for a rotation, optionally about a supplied centre point.
Arguments:
- angle (float): the angle of rotation (in degrees).
- centre (tuple[float], optional): An option centre location. Defaults to
- None, which will in turn be converted to (0, 0).
Returns:
list[float]: a six item list of floats, per the shapely.affinity. affine_transform method, see https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
982def get_reflection_transform( 983 angle:float, centre:tuple[float] = None) -> list[float]: 984 """Returns a shapely affine transform tuple that will reflect a shape 985 in a line at the specified angle, optionally through a specified centre 986 point. 987 988 Args: 989 angle (float): angle to the x-axis of the line of reflection. 990 centre (tuple[float], optional): point through which the line of 991 reflection passes. Defaults to None, which 992 will in turn be converted to (0, 0). 993 994 Returns: 995 list[float]: a six item list of floats, per the shapely.affinity. 996 affine_transform method, see 997 https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations 998 """ 999 if centre is None or np.allclose((0, 0), centre, atol = RESOLUTION): 1000 A = 2 * np.radians(angle) 1001 return (np.cos(A), np.sin(A), np.sin(A), -np.cos(A), 0, 0) 1002 dx, dy = centre 1003 t1 = get_translation_transform(-dx, -dy) 1004 r = get_reflection_transform(angle) 1005 t2 = get_translation_transform(dx, dy) 1006 return combine_transforms([t1, r, t2])
Returns a shapely affine transform tuple that will reflect a shape in a line at the specified angle, optionally through a specified centre point.
Arguments:
- angle (float): angle to the x-axis of the line of reflection.
- centre (tuple[float], optional): point through which the line of reflection passes. Defaults to None, which will in turn be converted to (0, 0).
Returns:
list[float]: a six item list of floats, per the shapely.affinity. affine_transform method, see https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
1009def combine_transforms(transforms:list[list[float]]) -> list[float]: 1010 """Returns a shapely affine transform list that combines the listed 1011 sequence of transforms applied in order. 1012 1013 Args: 1014 transforms (list[list[float]]): sequence of transforms to combine. 1015 1016 Returns: 1017 list[float]: a transform tuple combining the supplied transforms applied 1018 in order, see 1019 https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations 1020 """ 1021 result = np.identity(3) 1022 for t in transforms: 1023 result = as_numpy_matrix(t) @ result 1024 return as_shapely_transform(result)
Returns a shapely affine transform list that combines the listed sequence of transforms applied in order.
Arguments:
- transforms (list[list[float]]): sequence of transforms to combine.
Returns:
list[float]: a transform tuple combining the supplied transforms applied in order, see https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
1027def reverse_transform(transform:list[float]) -> list[float]: 1028 """Returns the inverse shapely affine transform of the supplied transform. 1029 1030 Args: 1031 transform (list[float]): the transform for which the inverse is desired. 1032 1033 Returns: 1034 list[float]: shapely affine transform tuple that will invert the supplied 1035 transform. 1036 """ 1037 return as_shapely_transform( 1038 np.linalg.inv(as_numpy_matrix(transform)))
Returns the inverse shapely affine transform of the supplied transform.
Arguments:
- transform (list[float]): the transform for which the inverse is desired.
Returns:
list[float]: shapely affine transform tuple that will invert the supplied transform.
1041def as_shapely_transform(arr:np.array) -> list[float]: 1042 """Returns the shapely affine transform list equivalent to the supplied 1043 numpy matrix of a conventional augmented affine transform matrix. 1044 1045 Args: 1046 arr (np.array): augmented affine transform matrix of the desired 1047 transform. 1048 1049 Returns: 1050 list[float]: desired shapely affine transform list of floats. 1051 """ 1052 return [arr[0][0], arr[0][1], arr[1][0], arr[1][1], arr[0][2], arr[1][2]]
Returns the shapely affine transform list equivalent to the supplied numpy matrix of a conventional augmented affine transform matrix.
Arguments:
- arr (np.array): augmented affine transform matrix of the desired transform.
Returns:
list[float]: desired shapely affine transform list of floats.
1055def as_numpy_matrix(transform:list[float]) -> np.array: 1056 """Converts the supplied shapely affine transform list to an augmented 1057 affine transform matrix in numpy array form. This makes combining transforms 1058 much easier. 1059 1060 Args: 1061 transform (list[float]): the transform in shapely format. 1062 1063 Returns: 1064 np.array: the transform in numpy matrix format. 1065 """ 1066 return np.array([[transform[0], transform[1], transform[4]], 1067 [transform[2], transform[3], transform[5]], 1068 [ 0, 0, 1]])
Converts the supplied shapely affine transform list to an augmented affine transform matrix in numpy array form. This makes combining transforms much easier.
Arguments:
- transform (list[float]): the transform in shapely format.
Returns:
np.array: the transform in numpy matrix format.