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    #
 373    # TODO: this is not reliable for reasons I don't fully understand
 374    # there is a corresponding crude catch exception in incentre... but
 375    # this needs a more complete investigation
 376    mat = np.identity(n) + np.roll(np.identity(n), 1, axis = 1)
 377    fractions = np.linalg.inv(mat) @ side_lengths / side_lengths
 378    if not(np.isclose(np.mean(fractions), 
 379                      0.5, rtol= RESOLUTION, atol = RESOLUTION)):
 380      return False
 381    ones = [np.isclose(f, 1, rtol = RESOLUTION, atol = RESOLUTION) 
 382            for f in fractions]
 383    zeros = [np.isclose(f, 0, rtol = RESOLUTION, atol = RESOLUTION) 
 384             for f in fractions]
 385    negatives = [f < 0 for f in fractions]
 386    return not (any(ones) or any(zeros) or any(negatives))
 387  elif n == 4:
 388    # for quadrilaterals odd and even side lengths are equal
 389    return np.isclose(sum(side_lengths[0::2]), 
 390                      sum(side_lengths[1::2]), rtol = RESOLUTION)
 391  else:
 392    # other even numbers of sides... hard to avoid brute force
 393    bisectors = [get_angle_bisector(shape, i) for i in range(n)]
 394    intersects = [b1.intersection(b2) for b1, b2 in
 395                  zip(bisectors, bisectors[1:] + bisectors[:1])
 396                  if b1.intersects(b2)]
 397    distances = [i1.distance(i2) for i1, i2 in
 398                 zip(intersects, intersects[1:] + intersects[:1])]
 399    return all([d <= 2 * RESOLUTION for d in distances])
 400
 401
 402def incentre(shape:geom.Polygon) -> geom.Point:
 403  """A different polygon centre, which produces better results for some
 404  dual tilings where tiles are not regular polygons... see
 405  https://en.wikipedia.org/wiki/Incenter
 406
 407  This method relies on the polygon being tangential, i.e. there is an
 408  inscribed circle to which all sides of the polygon are tangent. It will
 409  work on all the polygons encountered in the Laves tilings, but is not
 410  guaranteed to work on all polygons.
 411
 412  Given that the polygon is tangential, the radius of the inscribed circle is
 413  the [apothem of the polygon](https://en.wikipedia.org/wiki/Apothem) given
 414  by 2 x Area / Perimeter. We apply a parallel offset of this size to two
 415  sides of the polygon and find their intersection to determine the centre of
 416  the circle, givng the incentre of the polygon.
 417
 418    Args:
 419    shape (geom.Polygon): the polygon.
 420
 421  Returns:
 422    geom.Point: the incentre of the polygon.
 423  """
 424  if is_regular_polygon(shape):
 425    return shape.centroid
 426  shape = ensure_cw(shape)
 427  if is_convex(shape):
 428    if is_tangential(shape):  # find the incentre
 429      corners = get_corners(shape, repeat_first = False)
 430      r = get_apothem_length(shape)
 431      e1 = geom.LineString(corners[:2]).parallel_offset(r, side = "right")
 432      e2 = geom.LineString(corners[1:3]).parallel_offset(r, side = "right")
 433      c = e1.intersection(e2)
 434      # is_tangential is unreliable, and sometimes we get to here and do not
 435      # get a Point, but a LineString, or even no intersection, so...
 436      return c if isinstance(c, geom.Point) else shape.centroid
 437  return shape.centroid
 438
 439
 440def get_apothem_length(shape:geom.Polygon) -> float:
 441  return 2 * shape.area / geom.LineString(shape.exterior.coords).length
 442
 443
 444def ensure_cw(shape:geom.Polygon) -> geom.Polygon:
 445  """Returns the polygon with its outer boundary vertices in clockwise order.
 446
 447  It is important to note that shapely.set_precision() imposes clockwise order
 448  on polygons, and since it is used widely throughout theses modules, it makes
 449  sense to impose this order.
 450
 451    Args:
 452    shape (geom.Polygon): the polygon.
 453
 454  Returns:
 455    geom.Polygon: the polygon in clockwise order.
 456  """
 457  if geom.LinearRing(shape.exterior.coords).is_ccw:
 458    return shape.reverse()
 459  else:
 460    return shape
 461
 462
 463def get_angle_bisector(shape:geom.Polygon, v = 0) -> geom.LineString:
 464  """Returns a line which is the angle bisector of the specified corner of the
 465  supplied polygon.
 466
 467  Args:
 468      shape (geom.Polygon): the polygon
 469      v (int, optional): index of the corner whose bisector is required. 
 470      Defaults to 0.
 471
 472  Returns:
 473      geom.LineString: line which bisects the specified corner.
 474  """
 475  pts = get_corners(shape, repeat_first = False)
 476  n = len(pts)
 477  p0 = pts[v % n]
 478  p1 = pts[(v + 1) % n]
 479  p2 = pts[(v - 1) % n]
 480  d01 = p0.distance(p1)
 481  d02 = p0.distance(p2)
 482  if d01 < d02:
 483    p2 = geom.LineString([p0, p2]).interpolate(d01)
 484  else:
 485    p1 = geom.LineString([p0, p1]).interpolate(d02)
 486  m = geom.Point((p1.x + p2.x) / 2, (p1.y + p2.y) / 2)
 487  # we have to scale this to much larger in case of an angle near 180
 488  return affine.scale(geom.LineString([p0, m]), 10, 10, origin = p0)
 489
 490
 491def get_side_bisector(shape:geom.Polygon, i:int = 0) -> geom.LineString:
 492  return affine.scale(affine.rotate(get_sides(shape)[i], 90), 100, 100)
 493
 494
 495def _get_interior_vertices(tiles:gpd.GeoDataFrame) -> gpd.GeoSeries:
 496  """Returns points not on the outer boundary of the supplied set
 497  of tiles.
 498
 499  Args:
 500    shapes (gpd.GeoDataFrame): a set of polygons.
 501
 502  Returns:
 503    gpd.GeoSeries: interior vertices of the set of polygons.
 504  """
 505  tiles = gridify(tiles.geometry)
 506  uu = safe_union(tiles, as_polygon = True)
 507  interior_pts = set()
 508  for tile in tiles:
 509    for corner in tile.exterior.coords:
 510      if uu.contains(geom.Point(corner).buffer(1e-3, cap_style = 3)):
 511        interior_pts.add(corner)
 512  return gridify(gpd.GeoSeries([geom.Point(p) for p in interior_pts]))
 513
 514
 515def gridify(
 516      gs:Union[gpd.GeoSeries, gpd.GeoDataFrame, geom.Polygon]
 517    ) -> Union[gpd.GeoSeries, gpd.GeoDataFrame, geom.Polygon]:
 518  """Returns the supplied GeoSeries rounded to the specified precision.
 519
 520  Args:
 521    gs (gpd.GeoSeries): geometries to gridify.
 522    precision (int, optional): digits of precision. Defaults to 6.
 523
 524  Returns:
 525    gpd.GeoSeries: the rounded geometries.
 526  """
 527  # return gpd.GeoSeries(
 528  #   list(gs.apply(
 529  #     wkt.dumps, rounding_precision = precision).apply(wkt.loads)))
 530  if isinstance(gs, (geom.Polygon, geom.Point, geom.MultiPoint,
 531                     geom.MultiPolygon, geom.LineString)) :
 532    return shapely.set_precision(gs, grid_size = RESOLUTION)
 533  if isinstance(gs, gpd.GeoSeries):
 534    return gpd.GeoSeries(
 535      [shapely.set_precision(g, grid_size = RESOLUTION) for g in list(gs)],
 536      crs = gs.crs)
 537  if isinstance(gs, gpd.GeoDataFrame):
 538    gs.geometry = gridify(gs.geometry)
 539    return gs
 540
 541
 542def get_dual_tile_unit(unit: TileUnit) -> gpd.GeoDataFrame:
 543  """Converts supplied TileUnit to a candidate GeoDataFrame of its dual
 544  TileUnit.
 545
 546  NOTE: this is complicated and not remotely guaranteed to work!
 547  a particular issue is that where to place the vertices of the faces
 548  of the dual with respect to the tiles in the original is ill-defined.
 549  This is because the dual process is topologically not metrically defined,
 550  so that exact vertex locations are ambiguous. Tiling duality is defined in
 551  Section 4.2 of Grunbaum B, Shephard G C, 1987 _Tilings and Patterns_ (W. H.
 552  Freeman and Company, New York)
 553
 554  NOTE: In general, this method will work only if all supplied tiles are
 555  regular polygons. A known exception is if the only non-regular polygons are
 556  triangles.
 557
 558  NOTE: 'clean' polygons are required. If supplied polygons have messy
 559  vertices with multiple points where there is only one proper point, bad
 560  things are likely to happen! Consider using `clean_polygon()` on the
 561  tile geometries.
 562
 563  Because of the above limitations, we only return a GeoDataFrame
 564  for inspection. However some `weavingspace.tile_unit.TileUnit` setup
 565  methods in `weavingspace.tiling_geometries` use this method, where we are
 566  confident the returned dual is valid.
 567
 568  Args:
 569    unit (TileUnit): the tiling for which the dual is required.
 570
 571  Returns:
 572    gpd.GeoDataFrame: GeoDataFrame that could be the tiles attribute for
 573      a TileUnit of the dual tiling.
 574  """
 575  # get a local patch of this Tiling
 576  local_patch = unit.get_local_patch(r = 1, include_0 = True)
 577  # Find the interior points of these tiles - these will be guaranteed
 578  # to have a sequence of surrounding tiles incident on them
 579  interior_pts = _get_interior_vertices(local_patch)
 580  # Compile a list of the polygons incident on the interior points
 581  cycles = []
 582  for pt in interior_pts:
 583    cycles.append(
 584      set([poly_id for poly_id, p in enumerate(local_patch.geometry)
 585           if pt.distance(p) < RESOLUTION * 2]))
 586  # convert the polygon ID sequences to (centroid.x, centroid.y, ID) tuples
 587  dual_faces = []
 588  for cycle in cycles:
 589    ids, pts = [], []
 590    for poly_id in cycle:
 591      ids.append(local_patch.tile_id[poly_id])
 592      poly = local_patch.geometry[poly_id]
 593      pts.append(incentre(poly))
 594    # sort them into CW order so they are well formed
 595    sorted_coords = sort_cw([(pt.x, pt.y, id)
 596                              for pt, id in zip(pts, ids)])
 597    dual_faces.append(
 598      (geom.Polygon([(pt_id[0], pt_id[1]) for pt_id in sorted_coords]),
 599       "".join([pt_id[2] for pt_id in sorted_coords])))
 600  # ensure the resulting face centroids are inside the original tile
 601  # displaced a little to avoid uncertainties at corners/edges
 602  # TODO: Check  the logic of this - it seems like dumb luck that it works...
 603  dual_faces = [(f, id) for f, id in dual_faces
 604                if affine.translate(
 605                  unit.prototile.geometry[0], RESOLUTION * 10, 
 606                  RESOLUTION * 10).contains(f.centroid)]
 607  gdf = gpd.GeoDataFrame(
 608    data = {"tile_id": [f[1] for f in dual_faces]}, crs = unit.crs,
 609    geometry = gridify(gpd.GeoSeries([f[0] for f in dual_faces])))
 610  # ensure no duplicates
 611  gdf = gdf.dissolve(by = "tile_id", as_index = False).explode(
 612    index_parts = False, ignore_index = True)
 613
 614  gdf.tile_id = relabel(gdf.tile_id)
 615  return gdf
 616
 617
 618def relabel(data:Iterable) -> list:
 619  """Returns supplied data reassigned with unique values from
 620  string.ascii_letters.
 621
 622  Args:
 623    data (Iterable): the data to relabel
 624
 625  Returns:
 626    list: the reassigned data
 627  """
 628  new_data = {}
 629  d_count = 0
 630  for d in data:
 631    if not d in new_data:
 632      new_data[d] = string.ascii_letters[d_count]
 633      d_count = d_count + 1
 634  return [new_data[d] for d in data]
 635
 636
 637def sort_cw(pts_ids:list[tuple[float, float, str]]):
 638  """Sorts supplied tuple of x, y, ID into clockwise order relative to their
 639  mean centre.
 640
 641  Args:
 642    pts_ids (list[tuple[float, float, str]]): A tuple of a pair of
 643      floats and a string.
 644
 645  Returns:
 646    list: a list in the same format as supplied sorted into
 647      clockwise order of the point locations.
 648  """
 649  x = [p[0] for p in pts_ids]
 650  y = [p[1] for p in pts_ids]
 651  cx, cy = np.mean(x), np.mean(y)
 652  dx = [_ - cx for _ in x]
 653  dy = [_ - cy for _ in y]
 654  angles = [np.arctan2(dy, dx) for dx, dy in zip(dx, dy)]
 655  d = dict(zip(angles, pts_ids))
 656  return [pt_id for angle, pt_id in reversed(sorted(d.items()))]
 657
 658
 659def order_of_pts_cw_around_centre(pts:list[geom.Point], centre:geom.Point):
 660  """Returns the order of the supplied points clockwise relative to supplied 
 661  centre point, i.e. a list of the indices in clockwise order.
 662
 663  Args:
 664      pts (list[geom.Point]): list of points to order.
 665      centre (geom.Point): centre relative to which CW order is determined.
 666
 667  Returns:
 668      _type_: list of reordered points.
 669  """
 670  dx = [p.x - centre.x for p in pts]
 671  dy = [p.y - centre.y for p in pts]
 672  angles = [np.arctan2(dy, dx) for dx, dy in zip(dx, dy)]
 673  d = dict(zip(angles, range(len(pts))))
 674  return [i for angle, i in reversed(sorted(d.items()))]
 675
 676
 677def write_map_to_layers(gdf:gpd.GeoDataFrame, fname:str = "output.gpkg",
 678                        tile_var:str = "tile_id") -> None:
 679  """Writes supplied GeoDataFrame to a GPKG file with layers based on
 680  the tile_var attribute.
 681
 682  Args:
 683    gdf (gpd.GeoDataFrame): the GeoDataFrame.
 684    fname (str, optional): filename to write.
 685    tile_var (str, optional): the attribute to use to separate
 686      output file into layers. Defaults to "tile_id".
 687  """
 688  grouped = gdf.groupby(tile_var, as_index = False)
 689  for e in pd.Series.unique(gdf[tile_var]):
 690    grouped.get_group(e).to_file(fname, layer = e, driver = "GPKG")
 691
 692
 693def get_collapse_distance(geometry:geom.Polygon) -> float:
 694  """Returns the distance under which the supplied polygon will shrink
 695  to nothing if negatively buffered by that distance.
 696
 697  Performs a binary search between an upper bound based on the radius of
 698  the circle of equal area to the polygon, and 0.
 699
 700  Args:
 701    geometry (geom.Polygon): the polygon.
 702
 703  Returns:
 704    float: its collapse distance.
 705  """
 706  if is_convex(geometry):
 707    return get_apothem_length(geometry)
 708  radius = np.sqrt(geometry.area / np.pi)
 709  lower = 0
 710  upper = radius
 711  delta = 1e12
 712  stop_delta = radius / 1000
 713  while delta > stop_delta:
 714    new_r = (lower + upper) / 2
 715    if geometry.buffer(-new_r).area > 0:
 716      lower = new_r
 717      delta = upper - new_r
 718    else:
 719      upper = new_r
 720      delta = new_r - lower
 721  return new_r
 722
 723
 724def get_largest_polygon(polygons:gpd.GeoSeries) -> gpd.GeoSeries:
 725  """Returns the largest polygon in a GeoSeries as a GeoSeries of one polygon.
 726
 727  Args:
 728    polygons (gpd.GeoSeries): the set of polygons to pick from.
 729
 730  Returns:
 731    gpd.GeoSeries: the largest polygon.
 732  """
 733  actual_polygons = [p for p in polygons.geometry
 734                     if isinstance(p, (geom.Polygon, geom.MultiPolygon))]
 735  areas = [p.area for p in actual_polygons]
 736  max_area = max(areas)
 737  return gpd.GeoSeries([actual_polygons[areas.index(max_area)]])
 738
 739
 740def touch_along_an_edge(p1:geom.Polygon, p2:geom.Polygon) -> bool:
 741  """Tests if two polygons touch along an edge.
 742
 743  Checks that the intersection area of the two polygons buffered by
 744  a small amount is large enough to indicate that they neighbour at more
 745  than a corner.
 746
 747  Args:
 748    p1 (geom.Polygon): First polygon
 749    p2 (geom.Polygon): Second polygon
 750
 751  Returns:
 752    bool: True if they neighbour along an edge
 753  """
 754  return p1.buffer(RESOLUTION, join_style = 2, cap_style = 3) \
 755    .intersection(p2.buffer(RESOLUTION, join_style = 2, cap_style = 3)) \
 756      .area > 16 * RESOLUTION ** 2
 757
 758
 759def get_width_height_left_bottom(gs:gpd.GeoSeries) -> tuple[float]:
 760  """Returns width, height, left and bottom limits of a GeoSeries
 761
 762  Args:
 763    gs (geopandas.GeoSeries): GeoSeries for which limits are required.
 764
 765  Returns:
 766    tuple: four float values of width, height, left and bottom of gs.
 767  """
 768  extent = gs.total_bounds
 769  return (extent[2] - extent[0], extent[3] - extent[1],
 770      extent[0], extent[1])
 771
 772
 773def get_bounding_ellipse(shapes:gpd.GeoSeries, 
 774                         mag:float = 1.0) -> gpd.GeoSeries:
 775  """Returns an ellipse containing the supplied shapes.
 776
 777  The method used is to calculate the size of square that would contain
 778  the shapes, if they had an aspect ratio 1, then stretch the circle in
 779  the x, y directions according to the actual aspect ratio of the shapes.
 780
 781  Args:
 782    shapes (gpd.GeoSeries): the shapes to be contained.
 783    mag (float, optional): optionally increase the size of the returned
 784      ellipse by this scale factor. Defaults to 1.0.
 785
 786  Returns:
 787    gpd.GeoSeries: the set of shapes.
 788  """
 789
 790  w, h, l, b = get_width_height_left_bottom(shapes)
 791
 792  c = geom.Point(l + w / 2, b + h / 2)
 793  r = min(w, h) * np.sqrt(2)
 794  circle = [c.buffer(r)]
 795  return gridify(gpd.GeoSeries(circle, crs = shapes.crs).scale(
 796    w / r * mag / np.sqrt(2), h / r * mag / np.sqrt(2), origin = c))
 797
 798
 799def get_tiling_edges(tiles:gpd.GeoSeries) -> gpd.GeoSeries:
 800  """Returns linestring GeoSeries from supplied polygon GeoSeries.
 801
 802  This is used to allow display of edges of tiles in legend when they are
 803  masked by an ellipse (if we instead clip polygons then the ellipse edge
 804  will also show in the result.)
 805
 806  Args:
 807    shapes (gpd.GeoSeries): Polygons to convert.
 808
 809  Returns:
 810    gpd.GeoSeries: LineStrings from the supplied Polygons.
 811  """
 812  tiles = tiles.explode(ignore_index = True)
 813  edges = [geom.LineString(p.exterior.coords) for p in tiles]
 814  return gpd.GeoSeries(edges, crs = tiles.crs)
 815
 816
 817def get_polygon_sector(polygon:geom.Polygon, start:float = 0.0,
 818                       end:float = 1.0) -> geom.Polygon:
 819  """Returns a sector of the provided Polygon.
 820
 821  The returned sector is a section of the polygon boundary between the
 822  normalized start and end positions, and including the polygon centroid.
 823  Should (probably) only be applied to convex polygons.
 824
 825  Args:
 826    shape (geom.Polygon): the Polygon.
 827    start (float): normalized start position along the boundary. Defaults to
 828      0.
 829    end (float): normalized start position along the boundary. Defaults to
 830      1.
 831
 832  Returns:
 833    geom.Polygon: the requested polygon sector.
 834  """
 835  if start == end:
 836    # must return a null polygon since the calling context
 837    # expects to get something back... which most likely
 838    # is needed to align with other data
 839    return geom.Polygon()
 840  if start * end < 0:
 841    # either side of 0/1 so assume required sector includes 0
 842    e1, e2 = min(start, end), max(start, end)
 843    arc1 = shapely.ops.substring(geom.LineString(polygon.exterior.coords),
 844                                 np.mod(e1, 1), 1, normalized = True)
 845    arc2 = shapely.ops.substring(geom.LineString(polygon.exterior.coords),
 846                                 0, e2, normalized = True)
 847    sector = geom.Polygon([polygon.centroid] +
 848               list(arc1.coords) +
 849               list(arc2.coords)[1:])
 850  else:
 851    arc = shapely.ops.substring(geom.LineString(polygon.exterior.coords),
 852                                start, end, normalized = True)
 853    sector = geom.Polygon([polygon.centroid] + list(arc.coords))
 854  return gridify(sector)
 855
 856
 857def repair_polygon(
 858    polygon:Union[geom.Polygon, geom.MultiPolygon, gpd.GeoSeries],
 859    shrink_then_grow:bool = True) -> Union[geom.Polygon, gpd.GeoSeries]:
 860  """Convenience function to 'repair' a shapely polyon or GeoSeries by applying
 861  a negative buffer then the same positive buffer.
 862
 863  Optionally the buffer may be applied in the opposite order (i.e. grow then
 864  shrink). This operation may also convert a MultiPolygon that has some 'stray'
 865  parts to a Polygon.
 866
 867  This is method is often unofficially recommended (on stackexchange etc.)
 868  even in the shapely docs, to resolve topology issues and extraneous
 869  additional vertices appearing when spatial operations are repeatedly
 870  applied.
 871
 872  Args:
 873    p (Union[geom.Polygon, gpd.GeoSeries]): Polygon or GeoSeries to clean.
 874    res (float, optional): buffer size to use. Defaults to 1e-3.
 875    shrink_then_grow (bool, optional): if True the negative buffer is
 876      applied first, otherwise the buffer operations are applied in
 877      reverse. Defaults to True.
 878
 879  Returns:
 880    Union[geom.Polygon, gpd.GeoSeries]: the cleaned Polygon or GeoSeries.
 881  """
 882  if shrink_then_grow:
 883    return polygon.buffer(
 884      -RESOLUTION * 10, join_style = 2, cap_style = 3).buffer(
 885      RESOLUTION * 10, join_style = 2, cap_style = 3)
 886  else:
 887    return polygon.buffer(
 888      RESOLUTION * 10, join_style = 2, cap_style = 3).buffer(
 889      -RESOLUTION * 10, join_style = 2, cap_style = 3)
 890
 891
 892def safe_union(gs:gpd.GeoSeries,
 893               as_polygon:bool = False) -> Union[gpd.GeoSeries, geom.Polygon]:
 894  """Unions the supplied GeoSeries of Polygons while buffering them to avoid
 895  gaps and odd internal floating edges. Optionally returns a Polygon or a
 896  GeoSeries.
 897
 898  Frequently when unioning polygons that are ostensibly adjacent 'rogue'
 899  internal boundaries remain in the result. We can avoid this by buffering the
 900  polygons before unioning them, then reversing the buffer on the unioned
 901  shape.
 902
 903  Args:
 904    gs (gpd.GeoSeries): the Polygons to union.
 905    res (float, optional): size of the buffer to use. Defaults to 1e-3.
 906    as_polygon (bool, optional): if True returns a Polygon, otherwise
 907      returns a one Polygon GeoSeries. Defaults to False.
 908
 909  Returns:
 910    Union[gpd.GeoSeries, geom.Polygon]: the resulting union of supplied
 911      polygons.
 912  """
 913  union = gs.buffer(RESOLUTION * 10, join_style = 2, cap_style = 3) \
 914    .unary_union.buffer(-RESOLUTION * 10, join_style = 2, cap_style = 3)
 915  if as_polygon:
 916    return gridify(union)
 917  else:
 918    return gridify(gpd.GeoSeries([union], crs = gs.crs))
 919
 920
 921def zigzag_between_points(
 922    p0:geom.Point, p1: geom.Point, n:int, h:float = 1.0,  smoothness: int = 0):
 923
 924  template_steps = n * 2 + 1
 925  r = p0.distance(p1)
 926  
 927  x = np.linspace(0, n * np.pi, template_steps, endpoint = True)
 928  y = [np.sin(x) for x in x]
 929  s = interpolate.InterpolatedUnivariateSpline(x, y, k = 2)
 930
 931  spline_steps = (n + smoothness) * 2 + 1
 932  xs = np.linspace(0, n * np.pi, spline_steps, endpoint = True)
 933  ys = s(xs)
 934  
 935  sfx = 1 / max(x) * r
 936  sfy = h * r / 2
 937  theta = np.arctan2(p1.y - p0.y, p1.x - p0.x)
 938
 939  ls = geom.LineString([geom.Point(x, y) for x, y in zip(xs, ys)])
 940  ls = affine.translate(ls, 0, -(ls.bounds[1] + ls.bounds[3]) / 2)
 941  ls = affine.scale(ls, xfact = sfx, yfact = sfy, origin = (0, 0))
 942  ls = affine.rotate(ls, theta, (0, 0), use_radians = True)
 943  x0, y0 = list(ls.coords)[0]
 944  return affine.translate(ls, p0.x - x0, p0.y - y0)
 945
 946
 947def get_translation_transform(dx:float, dy:float) -> list[float]:
 948  """Returns the shapely affine transform tuple for a translation.
 949
 950  Args:
 951      dx (float): translation distance in x direction.
 952      dy (float): translation distance in y direction.
 953
 954  Returns:
 955    list[float]: a six item list of floats, per the shapely.affinity.
 956    affine_transform method, see 
 957      https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
 958  """
 959  return [1, 0, 0, 1, dx, dy]
 960
 961
 962def get_rotation_transform(
 963    angle:float, centre:tuple[float] = None) -> list[float]:
 964  """Returns the shapely affine transform tuple for a rotation, optionally
 965  about a supplied centre point.
 966
 967  Args:
 968      angle (float): the angle of rotation (in degrees).
 969      centre (tuple[float], optional): An option centre location. Defaults to 
 970      None, which will in turn be converted to (0, 0).
 971
 972  Returns:
 973    list[float]: a six item list of floats, per the shapely.affinity.
 974      affine_transform method, see 
 975        https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
 976  """
 977  if centre is None or np.allclose((0, 0), centre, atol = RESOLUTION):
 978    a = np.radians(angle)
 979    return [np.cos(a), -np.sin(a), np.sin(a), np.cos(a), 0, 0]
 980  
 981  dx, dy = centre
 982  t1 = get_translation_transform(-dx, -dy)
 983  r = get_rotation_transform(angle)
 984  t2 = get_translation_transform(dx, dy)
 985  return combine_transforms([t1, r, t2])
 986
 987
 988def get_reflection_transform(
 989    angle:float, centre:tuple[float] = None) -> list[float]:
 990  """Returns a shapely affine transform tuple that will reflect a shape
 991  in a line at the specified angle, optionally through a specified centre
 992  point.
 993
 994  Args:
 995    angle (float): angle to the x-axis of the line of reflection.
 996    centre (tuple[float], optional): point through which the line of 
 997      reflection passes. Defaults to None, which
 998      will in turn be converted to (0, 0).
 999
1000  Returns:
1001    list[float]: a six item list of floats, per the shapely.affinity.
1002      affine_transform method, see 
1003        https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
1004  """
1005  if centre is None or np.allclose((0, 0), centre, atol = RESOLUTION):
1006    A = 2 * np.radians(angle)
1007    return (np.cos(A), np.sin(A), np.sin(A), -np.cos(A), 0, 0)
1008  dx, dy = centre
1009  t1 = get_translation_transform(-dx, -dy)
1010  r = get_reflection_transform(angle)
1011  t2 = get_translation_transform(dx, dy)
1012  return combine_transforms([t1, r, t2])
1013
1014
1015def combine_transforms(transforms:list[list[float]]) -> list[float]:
1016  """Returns a shapely affine transform list that combines the listed
1017  sequence of transforms applied in order.
1018
1019  Args:
1020    transforms (list[list[float]]): sequence of transforms to combine.
1021
1022  Returns:
1023    list[float]: a transform tuple combining the supplied transforms applied
1024      in order, see 
1025        https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
1026  """
1027  result = np.identity(3)
1028  for t in transforms:
1029    result = as_numpy_matrix(t) @ result
1030  return as_shapely_transform(result)
1031
1032
1033def reverse_transform(transform:list[float]) -> list[float]:
1034  """Returns the inverse shapely affine transform of the supplied transform.
1035
1036  Args:
1037    transform (list[float]): the transform for which the inverse is desired.
1038
1039  Returns:
1040    list[float]: shapely affine transform tuple that will invert the supplied
1041      transform.
1042  """
1043  return as_shapely_transform(
1044    np.linalg.inv(as_numpy_matrix(transform)))
1045
1046
1047def as_shapely_transform(arr:np.array) -> list[float]:
1048  """Returns the shapely affine transform list equivalent to the supplied
1049  numpy matrix of a conventional augmented affine transform matrix.
1050
1051  Args:
1052    arr (np.array): augmented affine transform matrix of the desired 
1053      transform.
1054
1055  Returns:
1056    list[float]: desired shapely affine transform list of floats.
1057  """
1058  return [arr[0][0], arr[0][1], arr[1][0], arr[1][1], arr[0][2], arr[1][2]]
1059
1060
1061def as_numpy_matrix(transform:list[float]) -> np.array:
1062  """Converts the supplied shapely affine transform list to an augmented
1063  affine transform matrix in numpy array form. This makes combining transforms
1064  much easier.
1065
1066  Args:
1067    transform (list[float]): the transform in shapely format.
1068
1069  Returns:
1070    np.array: the transform in numpy matrix format.
1071  """
1072  return np.array([[transform[0], transform[1], transform[4]],
1073                   [transform[2], transform[3], transform[5]],
1074                   [           0,            0,            1]])
PRECISION = 6
RESOLUTION = 1e-06
def get_strand_ids(strands_spec: str) -> tuple[list[str]]:
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.

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

def centre_offset( shape: shapely.geometry.polygon.Polygon, target: tuple[float] = (0, 0)) -> tuple[float]:
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.

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

def get_regular_polygon(spacing, n: int) -> shapely.geometry.polygon.Polygon:
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.

Args: spacing (_type_): required height. n (int): number of sides.

Returns: geom.Polygon: required geom.Polygon.

def offset_polygon_corners( polygon: shapely.geometry.polygon.Polygon, offset: int) -> shapely.geometry.polygon.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.

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

def rotate_preserving_order( polygon: shapely.geometry.polygon.Polygon, angle: float, centre: shapely.geometry.point.Point) -> shapely.geometry.polygon.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).

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

def geometry_matches( geom1: shapely.geometry.polygon.Polygon, geom2: shapely.geometry.polygon.Polygon):
157def geometry_matches(geom1:geom.Polygon, geom2:geom.Polygon):
158  a = geom1.area
159  return np.isclose(a, geom1.intersection(geom2).area,
160                    rtol = RESOLUTION * 100, atol=RESOLUTION * 100)
161  return shapely.equals_exact(
162    geom1.normalize(), geom2.normalize(), RESOLUTION * 10)
def is_convex(shape: shapely.geometry.polygon.Polygon) -> bool:
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!

Args: shape (geom.Polygon): polygon to check

Returns: bool: True if the polygon is convex, False otherwise.

def get_corners( shape: shapely.geometry.polygon.Polygon, repeat_first: bool = True) -> list[shapely.geometry.point.Point]:
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.

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

def get_sides( shape: shapely.geometry.polygon.Polygon) -> list[shapely.geometry.linestring.LineString]:
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.

Args: shape (geom.Polygon): polygon whose edges are required.

Returns: list[geom.LineString]: list of geom.LineString sides of the polygon.

def get_side_lengths(shape: shapely.geometry.polygon.Polygon) -> list[float]:
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.

Args: shape (geom.Polygon): polygon whose edge lengths are required.

Returns: list[float]: list of side lengths.

def get_side_bearings(shape: shapely.geometry.polygon.Polygon) -> tuple[tuple[float]]:
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.

Args: shape (geom.Polygon): polygon whose side bearings are required.

Returns: tuple[tuple[float]]: tuple of bearings of each edge.

def get_interior_angles(shape: shapely.geometry.polygon.Polygon) -> list[float]:
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).

Args: shape (geom.Polygon): polygon whose angles are required.

Returns: list[float]: list of angles.

def get_clean_polygon( shape: Union[shapely.geometry.multipolygon.MultiPolygon, shapely.geometry.polygon.Polygon]) -> shapely.geometry.polygon.Polygon:
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.

Args: shape (Union[geom.MultiPolygon,geom.Polygon]): polygon to clean.

Returns: geom.Polygon: cleaned polygon.

def get_inner_angle( p1: shapely.geometry.point.Point, p2: shapely.geometry.point.Point, p3: shapely.geometry.point.Point) -> float:
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

Args: p1 (geom.Point): first point. p2 (geom.Point): second 'corner' point. p3 (geom.Point): third point.

Returns: float: angle in degrees.

def get_outer_angle( p1: shapely.geometry.point.Point, p2: shapely.geometry.point.Point, p3: shapely.geometry.point.Point) -> float:
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

Args: p1 (geom.Point): first point. p2 (geom.Point): second 'corner' point. p3 (geom.Point): third point.

Returns: float: angle in degrees.

def is_regular_polygon(shape: shapely.geometry.polygon.Polygon) -> bool:
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).

Args: shape (geom.Polygon): polygon to test.

Returns: bool: True if polygon is regular, False if not.

def is_tangential(shape: shapely.geometry.polygon.Polygon) -> bool:
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    #
374    # TODO: this is not reliable for reasons I don't fully understand
375    # there is a corresponding crude catch exception in incentre... but
376    # this needs a more complete investigation
377    mat = np.identity(n) + np.roll(np.identity(n), 1, axis = 1)
378    fractions = np.linalg.inv(mat) @ side_lengths / side_lengths
379    if not(np.isclose(np.mean(fractions), 
380                      0.5, rtol= RESOLUTION, atol = RESOLUTION)):
381      return False
382    ones = [np.isclose(f, 1, rtol = RESOLUTION, atol = RESOLUTION) 
383            for f in fractions]
384    zeros = [np.isclose(f, 0, rtol = RESOLUTION, atol = RESOLUTION) 
385             for f in fractions]
386    negatives = [f < 0 for f in fractions]
387    return not (any(ones) or any(zeros) or any(negatives))
388  elif n == 4:
389    # for quadrilaterals odd and even side lengths are equal
390    return np.isclose(sum(side_lengths[0::2]), 
391                      sum(side_lengths[1::2]), rtol = RESOLUTION)
392  else:
393    # other even numbers of sides... hard to avoid brute force
394    bisectors = [get_angle_bisector(shape, i) for i in range(n)]
395    intersects = [b1.intersection(b2) for b1, b2 in
396                  zip(bisectors, bisectors[1:] + bisectors[:1])
397                  if b1.intersects(b2)]
398    distances = [i1.distance(i2) for i1, i2 in
399                 zip(intersects, intersects[1:] + intersects[:1])]
400    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...

def incentre(shape: shapely.geometry.polygon.Polygon) -> shapely.geometry.point.Point:
403def incentre(shape:geom.Polygon) -> geom.Point:
404  """A different polygon centre, which produces better results for some
405  dual tilings where tiles are not regular polygons... see
406  https://en.wikipedia.org/wiki/Incenter
407
408  This method relies on the polygon being tangential, i.e. there is an
409  inscribed circle to which all sides of the polygon are tangent. It will
410  work on all the polygons encountered in the Laves tilings, but is not
411  guaranteed to work on all polygons.
412
413  Given that the polygon is tangential, the radius of the inscribed circle is
414  the [apothem of the polygon](https://en.wikipedia.org/wiki/Apothem) given
415  by 2 x Area / Perimeter. We apply a parallel offset of this size to two
416  sides of the polygon and find their intersection to determine the centre of
417  the circle, givng the incentre of the polygon.
418
419    Args:
420    shape (geom.Polygon): the polygon.
421
422  Returns:
423    geom.Point: the incentre of the polygon.
424  """
425  if is_regular_polygon(shape):
426    return shape.centroid
427  shape = ensure_cw(shape)
428  if is_convex(shape):
429    if is_tangential(shape):  # find the incentre
430      corners = get_corners(shape, repeat_first = False)
431      r = get_apothem_length(shape)
432      e1 = geom.LineString(corners[:2]).parallel_offset(r, side = "right")
433      e2 = geom.LineString(corners[1:3]).parallel_offset(r, side = "right")
434      c = e1.intersection(e2)
435      # is_tangential is unreliable, and sometimes we get to here and do not
436      # get a Point, but a LineString, or even no intersection, so...
437      return c if isinstance(c, geom.Point) else shape.centroid
438  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.

def get_apothem_length(shape: shapely.geometry.polygon.Polygon) -> float:
441def get_apothem_length(shape:geom.Polygon) -> float:
442  return 2 * shape.area / geom.LineString(shape.exterior.coords).length
def ensure_cw( shape: shapely.geometry.polygon.Polygon) -> shapely.geometry.polygon.Polygon:
445def ensure_cw(shape:geom.Polygon) -> geom.Polygon:
446  """Returns the polygon with its outer boundary vertices in clockwise order.
447
448  It is important to note that shapely.set_precision() imposes clockwise order
449  on polygons, and since it is used widely throughout theses modules, it makes
450  sense to impose this order.
451
452    Args:
453    shape (geom.Polygon): the polygon.
454
455  Returns:
456    geom.Polygon: the polygon in clockwise order.
457  """
458  if geom.LinearRing(shape.exterior.coords).is_ccw:
459    return shape.reverse()
460  else:
461    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.

def get_angle_bisector( shape: shapely.geometry.polygon.Polygon, v=0) -> shapely.geometry.linestring.LineString:
464def get_angle_bisector(shape:geom.Polygon, v = 0) -> geom.LineString:
465  """Returns a line which is the angle bisector of the specified corner of the
466  supplied polygon.
467
468  Args:
469      shape (geom.Polygon): the polygon
470      v (int, optional): index of the corner whose bisector is required. 
471      Defaults to 0.
472
473  Returns:
474      geom.LineString: line which bisects the specified corner.
475  """
476  pts = get_corners(shape, repeat_first = False)
477  n = len(pts)
478  p0 = pts[v % n]
479  p1 = pts[(v + 1) % n]
480  p2 = pts[(v - 1) % n]
481  d01 = p0.distance(p1)
482  d02 = p0.distance(p2)
483  if d01 < d02:
484    p2 = geom.LineString([p0, p2]).interpolate(d01)
485  else:
486    p1 = geom.LineString([p0, p1]).interpolate(d02)
487  m = geom.Point((p1.x + p2.x) / 2, (p1.y + p2.y) / 2)
488  # we have to scale this to much larger in case of an angle near 180
489  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.

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

def get_side_bisector( shape: shapely.geometry.polygon.Polygon, i: int = 0) -> shapely.geometry.linestring.LineString:
492def get_side_bisector(shape:geom.Polygon, i:int = 0) -> geom.LineString:
493  return affine.scale(affine.rotate(get_sides(shape)[i], 90), 100, 100)
def gridify( gs: Union[geopandas.geoseries.GeoSeries, geopandas.geodataframe.GeoDataFrame, shapely.geometry.polygon.Polygon]) -> Union[geopandas.geoseries.GeoSeries, geopandas.geodataframe.GeoDataFrame, shapely.geometry.polygon.Polygon]:
516def gridify(
517      gs:Union[gpd.GeoSeries, gpd.GeoDataFrame, geom.Polygon]
518    ) -> Union[gpd.GeoSeries, gpd.GeoDataFrame, geom.Polygon]:
519  """Returns the supplied GeoSeries rounded to the specified precision.
520
521  Args:
522    gs (gpd.GeoSeries): geometries to gridify.
523    precision (int, optional): digits of precision. Defaults to 6.
524
525  Returns:
526    gpd.GeoSeries: the rounded geometries.
527  """
528  # return gpd.GeoSeries(
529  #   list(gs.apply(
530  #     wkt.dumps, rounding_precision = precision).apply(wkt.loads)))
531  if isinstance(gs, (geom.Polygon, geom.Point, geom.MultiPoint,
532                     geom.MultiPolygon, geom.LineString)) :
533    return shapely.set_precision(gs, grid_size = RESOLUTION)
534  if isinstance(gs, gpd.GeoSeries):
535    return gpd.GeoSeries(
536      [shapely.set_precision(g, grid_size = RESOLUTION) for g in list(gs)],
537      crs = gs.crs)
538  if isinstance(gs, gpd.GeoDataFrame):
539    gs.geometry = gridify(gs.geometry)
540    return gs

Returns the supplied GeoSeries rounded to the specified precision.

Args: gs (gpd.GeoSeries): geometries to gridify. precision (int, optional): digits of precision. Defaults to 6.

Returns: gpd.GeoSeries: the rounded geometries.

def get_dual_tile_unit( unit: weavingspace.tile_unit.TileUnit) -> geopandas.geodataframe.GeoDataFrame:
543def get_dual_tile_unit(unit: TileUnit) -> gpd.GeoDataFrame:
544  """Converts supplied TileUnit to a candidate GeoDataFrame of its dual
545  TileUnit.
546
547  NOTE: this is complicated and not remotely guaranteed to work!
548  a particular issue is that where to place the vertices of the faces
549  of the dual with respect to the tiles in the original is ill-defined.
550  This is because the dual process is topologically not metrically defined,
551  so that exact vertex locations are ambiguous. Tiling duality is defined in
552  Section 4.2 of Grunbaum B, Shephard G C, 1987 _Tilings and Patterns_ (W. H.
553  Freeman and Company, New York)
554
555  NOTE: In general, this method will work only if all supplied tiles are
556  regular polygons. A known exception is if the only non-regular polygons are
557  triangles.
558
559  NOTE: 'clean' polygons are required. If supplied polygons have messy
560  vertices with multiple points where there is only one proper point, bad
561  things are likely to happen! Consider using `clean_polygon()` on the
562  tile geometries.
563
564  Because of the above limitations, we only return a GeoDataFrame
565  for inspection. However some `weavingspace.tile_unit.TileUnit` setup
566  methods in `weavingspace.tiling_geometries` use this method, where we are
567  confident the returned dual is valid.
568
569  Args:
570    unit (TileUnit): the tiling for which the dual is required.
571
572  Returns:
573    gpd.GeoDataFrame: GeoDataFrame that could be the tiles attribute for
574      a TileUnit of the dual tiling.
575  """
576  # get a local patch of this Tiling
577  local_patch = unit.get_local_patch(r = 1, include_0 = True)
578  # Find the interior points of these tiles - these will be guaranteed
579  # to have a sequence of surrounding tiles incident on them
580  interior_pts = _get_interior_vertices(local_patch)
581  # Compile a list of the polygons incident on the interior points
582  cycles = []
583  for pt in interior_pts:
584    cycles.append(
585      set([poly_id for poly_id, p in enumerate(local_patch.geometry)
586           if pt.distance(p) < RESOLUTION * 2]))
587  # convert the polygon ID sequences to (centroid.x, centroid.y, ID) tuples
588  dual_faces = []
589  for cycle in cycles:
590    ids, pts = [], []
591    for poly_id in cycle:
592      ids.append(local_patch.tile_id[poly_id])
593      poly = local_patch.geometry[poly_id]
594      pts.append(incentre(poly))
595    # sort them into CW order so they are well formed
596    sorted_coords = sort_cw([(pt.x, pt.y, id)
597                              for pt, id in zip(pts, ids)])
598    dual_faces.append(
599      (geom.Polygon([(pt_id[0], pt_id[1]) for pt_id in sorted_coords]),
600       "".join([pt_id[2] for pt_id in sorted_coords])))
601  # ensure the resulting face centroids are inside the original tile
602  # displaced a little to avoid uncertainties at corners/edges
603  # TODO: Check  the logic of this - it seems like dumb luck that it works...
604  dual_faces = [(f, id) for f, id in dual_faces
605                if affine.translate(
606                  unit.prototile.geometry[0], RESOLUTION * 10, 
607                  RESOLUTION * 10).contains(f.centroid)]
608  gdf = gpd.GeoDataFrame(
609    data = {"tile_id": [f[1] for f in dual_faces]}, crs = unit.crs,
610    geometry = gridify(gpd.GeoSeries([f[0] for f in dual_faces])))
611  # ensure no duplicates
612  gdf = gdf.dissolve(by = "tile_id", as_index = False).explode(
613    index_parts = False, ignore_index = True)
614
615  gdf.tile_id = relabel(gdf.tile_id)
616  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.

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

def relabel(data: Iterable) -> list:
619def relabel(data:Iterable) -> list:
620  """Returns supplied data reassigned with unique values from
621  string.ascii_letters.
622
623  Args:
624    data (Iterable): the data to relabel
625
626  Returns:
627    list: the reassigned data
628  """
629  new_data = {}
630  d_count = 0
631  for d in data:
632    if not d in new_data:
633      new_data[d] = string.ascii_letters[d_count]
634      d_count = d_count + 1
635  return [new_data[d] for d in data]

Returns supplied data reassigned with unique values from string.ascii_letters.

Args: data (Iterable): the data to relabel

Returns: list: the reassigned data

def sort_cw(pts_ids: list[tuple[float, float, str]]):
638def sort_cw(pts_ids:list[tuple[float, float, str]]):
639  """Sorts supplied tuple of x, y, ID into clockwise order relative to their
640  mean centre.
641
642  Args:
643    pts_ids (list[tuple[float, float, str]]): A tuple of a pair of
644      floats and a string.
645
646  Returns:
647    list: a list in the same format as supplied sorted into
648      clockwise order of the point locations.
649  """
650  x = [p[0] for p in pts_ids]
651  y = [p[1] for p in pts_ids]
652  cx, cy = np.mean(x), np.mean(y)
653  dx = [_ - cx for _ in x]
654  dy = [_ - cy for _ in y]
655  angles = [np.arctan2(dy, dx) for dx, dy in zip(dx, dy)]
656  d = dict(zip(angles, pts_ids))
657  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.

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

def order_of_pts_cw_around_centre( pts: list[shapely.geometry.point.Point], centre: shapely.geometry.point.Point):
660def order_of_pts_cw_around_centre(pts:list[geom.Point], centre:geom.Point):
661  """Returns the order of the supplied points clockwise relative to supplied 
662  centre point, i.e. a list of the indices in clockwise order.
663
664  Args:
665      pts (list[geom.Point]): list of points to order.
666      centre (geom.Point): centre relative to which CW order is determined.
667
668  Returns:
669      _type_: list of reordered points.
670  """
671  dx = [p.x - centre.x for p in pts]
672  dy = [p.y - centre.y for p in pts]
673  angles = [np.arctan2(dy, dx) for dx, dy in zip(dx, dy)]
674  d = dict(zip(angles, range(len(pts))))
675  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.

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

def write_map_to_layers( gdf: geopandas.geodataframe.GeoDataFrame, fname: str = 'output.gpkg', tile_var: str = 'tile_id') -> None:
678def write_map_to_layers(gdf:gpd.GeoDataFrame, fname:str = "output.gpkg",
679                        tile_var:str = "tile_id") -> None:
680  """Writes supplied GeoDataFrame to a GPKG file with layers based on
681  the tile_var attribute.
682
683  Args:
684    gdf (gpd.GeoDataFrame): the GeoDataFrame.
685    fname (str, optional): filename to write.
686    tile_var (str, optional): the attribute to use to separate
687      output file into layers. Defaults to "tile_id".
688  """
689  grouped = gdf.groupby(tile_var, as_index = False)
690  for e in pd.Series.unique(gdf[tile_var]):
691    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.

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

def get_collapse_distance(geometry: shapely.geometry.polygon.Polygon) -> float:
694def get_collapse_distance(geometry:geom.Polygon) -> float:
695  """Returns the distance under which the supplied polygon will shrink
696  to nothing if negatively buffered by that distance.
697
698  Performs a binary search between an upper bound based on the radius of
699  the circle of equal area to the polygon, and 0.
700
701  Args:
702    geometry (geom.Polygon): the polygon.
703
704  Returns:
705    float: its collapse distance.
706  """
707  if is_convex(geometry):
708    return get_apothem_length(geometry)
709  radius = np.sqrt(geometry.area / np.pi)
710  lower = 0
711  upper = radius
712  delta = 1e12
713  stop_delta = radius / 1000
714  while delta > stop_delta:
715    new_r = (lower + upper) / 2
716    if geometry.buffer(-new_r).area > 0:
717      lower = new_r
718      delta = upper - new_r
719    else:
720      upper = new_r
721      delta = new_r - lower
722  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.

Args: geometry (geom.Polygon): the polygon.

Returns: float: its collapse distance.

def get_largest_polygon(polygons: geopandas.geoseries.GeoSeries) -> geopandas.geoseries.GeoSeries:
725def get_largest_polygon(polygons:gpd.GeoSeries) -> gpd.GeoSeries:
726  """Returns the largest polygon in a GeoSeries as a GeoSeries of one polygon.
727
728  Args:
729    polygons (gpd.GeoSeries): the set of polygons to pick from.
730
731  Returns:
732    gpd.GeoSeries: the largest polygon.
733  """
734  actual_polygons = [p for p in polygons.geometry
735                     if isinstance(p, (geom.Polygon, geom.MultiPolygon))]
736  areas = [p.area for p in actual_polygons]
737  max_area = max(areas)
738  return gpd.GeoSeries([actual_polygons[areas.index(max_area)]])

Returns the largest polygon in a GeoSeries as a GeoSeries of one polygon.

Args: polygons (gpd.GeoSeries): the set of polygons to pick from.

Returns: gpd.GeoSeries: the largest polygon.

def touch_along_an_edge( p1: shapely.geometry.polygon.Polygon, p2: shapely.geometry.polygon.Polygon) -> bool:
741def touch_along_an_edge(p1:geom.Polygon, p2:geom.Polygon) -> bool:
742  """Tests if two polygons touch along an edge.
743
744  Checks that the intersection area of the two polygons buffered by
745  a small amount is large enough to indicate that they neighbour at more
746  than a corner.
747
748  Args:
749    p1 (geom.Polygon): First polygon
750    p2 (geom.Polygon): Second polygon
751
752  Returns:
753    bool: True if they neighbour along an edge
754  """
755  return p1.buffer(RESOLUTION, join_style = 2, cap_style = 3) \
756    .intersection(p2.buffer(RESOLUTION, join_style = 2, cap_style = 3)) \
757      .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.

Args: p1 (geom.Polygon): First polygon p2 (geom.Polygon): Second polygon

Returns: bool: True if they neighbour along an edge

def get_width_height_left_bottom(gs: geopandas.geoseries.GeoSeries) -> tuple[float]:
760def get_width_height_left_bottom(gs:gpd.GeoSeries) -> tuple[float]:
761  """Returns width, height, left and bottom limits of a GeoSeries
762
763  Args:
764    gs (geopandas.GeoSeries): GeoSeries for which limits are required.
765
766  Returns:
767    tuple: four float values of width, height, left and bottom of gs.
768  """
769  extent = gs.total_bounds
770  return (extent[2] - extent[0], extent[3] - extent[1],
771      extent[0], extent[1])

Returns width, height, left and bottom limits of a GeoSeries

Args: gs (geopandas.GeoSeries): GeoSeries for which limits are required.

Returns: tuple: four float values of width, height, left and bottom of gs.

def get_bounding_ellipse( shapes: geopandas.geoseries.GeoSeries, mag: float = 1.0) -> geopandas.geoseries.GeoSeries:
774def get_bounding_ellipse(shapes:gpd.GeoSeries, 
775                         mag:float = 1.0) -> gpd.GeoSeries:
776  """Returns an ellipse containing the supplied shapes.
777
778  The method used is to calculate the size of square that would contain
779  the shapes, if they had an aspect ratio 1, then stretch the circle in
780  the x, y directions according to the actual aspect ratio of the shapes.
781
782  Args:
783    shapes (gpd.GeoSeries): the shapes to be contained.
784    mag (float, optional): optionally increase the size of the returned
785      ellipse by this scale factor. Defaults to 1.0.
786
787  Returns:
788    gpd.GeoSeries: the set of shapes.
789  """
790
791  w, h, l, b = get_width_height_left_bottom(shapes)
792
793  c = geom.Point(l + w / 2, b + h / 2)
794  r = min(w, h) * np.sqrt(2)
795  circle = [c.buffer(r)]
796  return gridify(gpd.GeoSeries(circle, crs = shapes.crs).scale(
797    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.

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

def get_tiling_edges(tiles: geopandas.geoseries.GeoSeries) -> geopandas.geoseries.GeoSeries:
800def get_tiling_edges(tiles:gpd.GeoSeries) -> gpd.GeoSeries:
801  """Returns linestring GeoSeries from supplied polygon GeoSeries.
802
803  This is used to allow display of edges of tiles in legend when they are
804  masked by an ellipse (if we instead clip polygons then the ellipse edge
805  will also show in the result.)
806
807  Args:
808    shapes (gpd.GeoSeries): Polygons to convert.
809
810  Returns:
811    gpd.GeoSeries: LineStrings from the supplied Polygons.
812  """
813  tiles = tiles.explode(ignore_index = True)
814  edges = [geom.LineString(p.exterior.coords) for p in tiles]
815  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.)

Args: shapes (gpd.GeoSeries): Polygons to convert.

Returns: gpd.GeoSeries: LineStrings from the supplied Polygons.

def get_polygon_sector( polygon: shapely.geometry.polygon.Polygon, start: float = 0.0, end: float = 1.0) -> shapely.geometry.polygon.Polygon:
818def get_polygon_sector(polygon:geom.Polygon, start:float = 0.0,
819                       end:float = 1.0) -> geom.Polygon:
820  """Returns a sector of the provided Polygon.
821
822  The returned sector is a section of the polygon boundary between the
823  normalized start and end positions, and including the polygon centroid.
824  Should (probably) only be applied to convex polygons.
825
826  Args:
827    shape (geom.Polygon): the Polygon.
828    start (float): normalized start position along the boundary. Defaults to
829      0.
830    end (float): normalized start position along the boundary. Defaults to
831      1.
832
833  Returns:
834    geom.Polygon: the requested polygon sector.
835  """
836  if start == end:
837    # must return a null polygon since the calling context
838    # expects to get something back... which most likely
839    # is needed to align with other data
840    return geom.Polygon()
841  if start * end < 0:
842    # either side of 0/1 so assume required sector includes 0
843    e1, e2 = min(start, end), max(start, end)
844    arc1 = shapely.ops.substring(geom.LineString(polygon.exterior.coords),
845                                 np.mod(e1, 1), 1, normalized = True)
846    arc2 = shapely.ops.substring(geom.LineString(polygon.exterior.coords),
847                                 0, e2, normalized = True)
848    sector = geom.Polygon([polygon.centroid] +
849               list(arc1.coords) +
850               list(arc2.coords)[1:])
851  else:
852    arc = shapely.ops.substring(geom.LineString(polygon.exterior.coords),
853                                start, end, normalized = True)
854    sector = geom.Polygon([polygon.centroid] + list(arc.coords))
855  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.

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

def repair_polygon( polygon: Union[shapely.geometry.polygon.Polygon, shapely.geometry.multipolygon.MultiPolygon, geopandas.geoseries.GeoSeries], shrink_then_grow: bool = True) -> Union[shapely.geometry.polygon.Polygon, geopandas.geoseries.GeoSeries]:
858def repair_polygon(
859    polygon:Union[geom.Polygon, geom.MultiPolygon, gpd.GeoSeries],
860    shrink_then_grow:bool = True) -> Union[geom.Polygon, gpd.GeoSeries]:
861  """Convenience function to 'repair' a shapely polyon or GeoSeries by applying
862  a negative buffer then the same positive buffer.
863
864  Optionally the buffer may be applied in the opposite order (i.e. grow then
865  shrink). This operation may also convert a MultiPolygon that has some 'stray'
866  parts to a Polygon.
867
868  This is method is often unofficially recommended (on stackexchange etc.)
869  even in the shapely docs, to resolve topology issues and extraneous
870  additional vertices appearing when spatial operations are repeatedly
871  applied.
872
873  Args:
874    p (Union[geom.Polygon, gpd.GeoSeries]): Polygon or GeoSeries to clean.
875    res (float, optional): buffer size to use. Defaults to 1e-3.
876    shrink_then_grow (bool, optional): if True the negative buffer is
877      applied first, otherwise the buffer operations are applied in
878      reverse. Defaults to True.
879
880  Returns:
881    Union[geom.Polygon, gpd.GeoSeries]: the cleaned Polygon or GeoSeries.
882  """
883  if shrink_then_grow:
884    return polygon.buffer(
885      -RESOLUTION * 10, join_style = 2, cap_style = 3).buffer(
886      RESOLUTION * 10, join_style = 2, cap_style = 3)
887  else:
888    return polygon.buffer(
889      RESOLUTION * 10, join_style = 2, cap_style = 3).buffer(
890      -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.

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

def safe_union( gs: geopandas.geoseries.GeoSeries, as_polygon: bool = False) -> Union[geopandas.geoseries.GeoSeries, shapely.geometry.polygon.Polygon]:
893def safe_union(gs:gpd.GeoSeries,
894               as_polygon:bool = False) -> Union[gpd.GeoSeries, geom.Polygon]:
895  """Unions the supplied GeoSeries of Polygons while buffering them to avoid
896  gaps and odd internal floating edges. Optionally returns a Polygon or a
897  GeoSeries.
898
899  Frequently when unioning polygons that are ostensibly adjacent 'rogue'
900  internal boundaries remain in the result. We can avoid this by buffering the
901  polygons before unioning them, then reversing the buffer on the unioned
902  shape.
903
904  Args:
905    gs (gpd.GeoSeries): the Polygons to union.
906    res (float, optional): size of the buffer to use. Defaults to 1e-3.
907    as_polygon (bool, optional): if True returns a Polygon, otherwise
908      returns a one Polygon GeoSeries. Defaults to False.
909
910  Returns:
911    Union[gpd.GeoSeries, geom.Polygon]: the resulting union of supplied
912      polygons.
913  """
914  union = gs.buffer(RESOLUTION * 10, join_style = 2, cap_style = 3) \
915    .unary_union.buffer(-RESOLUTION * 10, join_style = 2, cap_style = 3)
916  if as_polygon:
917    return gridify(union)
918  else:
919    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.

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

def zigzag_between_points( p0: shapely.geometry.point.Point, p1: shapely.geometry.point.Point, n: int, h: float = 1.0, smoothness: int = 0):
922def zigzag_between_points(
923    p0:geom.Point, p1: geom.Point, n:int, h:float = 1.0,  smoothness: int = 0):
924
925  template_steps = n * 2 + 1
926  r = p0.distance(p1)
927  
928  x = np.linspace(0, n * np.pi, template_steps, endpoint = True)
929  y = [np.sin(x) for x in x]
930  s = interpolate.InterpolatedUnivariateSpline(x, y, k = 2)
931
932  spline_steps = (n + smoothness) * 2 + 1
933  xs = np.linspace(0, n * np.pi, spline_steps, endpoint = True)
934  ys = s(xs)
935  
936  sfx = 1 / max(x) * r
937  sfy = h * r / 2
938  theta = np.arctan2(p1.y - p0.y, p1.x - p0.x)
939
940  ls = geom.LineString([geom.Point(x, y) for x, y in zip(xs, ys)])
941  ls = affine.translate(ls, 0, -(ls.bounds[1] + ls.bounds[3]) / 2)
942  ls = affine.scale(ls, xfact = sfx, yfact = sfy, origin = (0, 0))
943  ls = affine.rotate(ls, theta, (0, 0), use_radians = True)
944  x0, y0 = list(ls.coords)[0]
945  return affine.translate(ls, p0.x - x0, p0.y - y0)
def get_translation_transform(dx: float, dy: float) -> list[float]:
948def get_translation_transform(dx:float, dy:float) -> list[float]:
949  """Returns the shapely affine transform tuple for a translation.
950
951  Args:
952      dx (float): translation distance in x direction.
953      dy (float): translation distance in y direction.
954
955  Returns:
956    list[float]: a six item list of floats, per the shapely.affinity.
957    affine_transform method, see 
958      https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
959  """
960  return [1, 0, 0, 1, dx, dy]

Returns the shapely affine transform tuple for a translation.

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

def get_rotation_transform(angle: float, centre: tuple[float] = None) -> list[float]:
963def get_rotation_transform(
964    angle:float, centre:tuple[float] = None) -> list[float]:
965  """Returns the shapely affine transform tuple for a rotation, optionally
966  about a supplied centre point.
967
968  Args:
969      angle (float): the angle of rotation (in degrees).
970      centre (tuple[float], optional): An option centre location. Defaults to 
971      None, which will in turn be converted to (0, 0).
972
973  Returns:
974    list[float]: a six item list of floats, per the shapely.affinity.
975      affine_transform method, see 
976        https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
977  """
978  if centre is None or np.allclose((0, 0), centre, atol = RESOLUTION):
979    a = np.radians(angle)
980    return [np.cos(a), -np.sin(a), np.sin(a), np.cos(a), 0, 0]
981  
982  dx, dy = centre
983  t1 = get_translation_transform(-dx, -dy)
984  r = get_rotation_transform(angle)
985  t2 = get_translation_transform(dx, dy)
986  return combine_transforms([t1, r, t2])

Returns the shapely affine transform tuple for a rotation, optionally about a supplied centre point.

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

def get_reflection_transform(angle: float, centre: tuple[float] = None) -> list[float]:
 989def get_reflection_transform(
 990    angle:float, centre:tuple[float] = None) -> list[float]:
 991  """Returns a shapely affine transform tuple that will reflect a shape
 992  in a line at the specified angle, optionally through a specified centre
 993  point.
 994
 995  Args:
 996    angle (float): angle to the x-axis of the line of reflection.
 997    centre (tuple[float], optional): point through which the line of 
 998      reflection passes. Defaults to None, which
 999      will in turn be converted to (0, 0).
1000
1001  Returns:
1002    list[float]: a six item list of floats, per the shapely.affinity.
1003      affine_transform method, see 
1004        https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
1005  """
1006  if centre is None or np.allclose((0, 0), centre, atol = RESOLUTION):
1007    A = 2 * np.radians(angle)
1008    return (np.cos(A), np.sin(A), np.sin(A), -np.cos(A), 0, 0)
1009  dx, dy = centre
1010  t1 = get_translation_transform(-dx, -dy)
1011  r = get_reflection_transform(angle)
1012  t2 = get_translation_transform(dx, dy)
1013  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.

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

def combine_transforms(transforms: list[list[float]]) -> list[float]:
1016def combine_transforms(transforms:list[list[float]]) -> list[float]:
1017  """Returns a shapely affine transform list that combines the listed
1018  sequence of transforms applied in order.
1019
1020  Args:
1021    transforms (list[list[float]]): sequence of transforms to combine.
1022
1023  Returns:
1024    list[float]: a transform tuple combining the supplied transforms applied
1025      in order, see 
1026        https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
1027  """
1028  result = np.identity(3)
1029  for t in transforms:
1030    result = as_numpy_matrix(t) @ result
1031  return as_shapely_transform(result)

Returns a shapely affine transform list that combines the listed sequence of transforms applied in order.

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

def reverse_transform(transform: list[float]) -> list[float]:
1034def reverse_transform(transform:list[float]) -> list[float]:
1035  """Returns the inverse shapely affine transform of the supplied transform.
1036
1037  Args:
1038    transform (list[float]): the transform for which the inverse is desired.
1039
1040  Returns:
1041    list[float]: shapely affine transform tuple that will invert the supplied
1042      transform.
1043  """
1044  return as_shapely_transform(
1045    np.linalg.inv(as_numpy_matrix(transform)))

Returns the inverse shapely affine transform of the supplied transform.

Args: transform (list[float]): the transform for which the inverse is desired.

Returns: list[float]: shapely affine transform tuple that will invert the supplied transform.

def as_shapely_transform(arr: <built-in function array>) -> list[float]:
1048def as_shapely_transform(arr:np.array) -> list[float]:
1049  """Returns the shapely affine transform list equivalent to the supplied
1050  numpy matrix of a conventional augmented affine transform matrix.
1051
1052  Args:
1053    arr (np.array): augmented affine transform matrix of the desired 
1054      transform.
1055
1056  Returns:
1057    list[float]: desired shapely affine transform list of floats.
1058  """
1059  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.

Args: arr (np.array): augmented affine transform matrix of the desired transform.

Returns: list[float]: desired shapely affine transform list of floats.

def as_numpy_matrix(transform: list[float]) -> <built-in function array>:
1062def as_numpy_matrix(transform:list[float]) -> np.array:
1063  """Converts the supplied shapely affine transform list to an augmented
1064  affine transform matrix in numpy array form. This makes combining transforms
1065  much easier.
1066
1067  Args:
1068    transform (list[float]): the transform in shapely format.
1069
1070  Returns:
1071    np.array: the transform in numpy matrix format.
1072  """
1073  return np.array([[transform[0], transform[1], transform[4]],
1074                   [transform[2], transform[3], transform[5]],
1075                   [           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.

Args: transform (list[float]): the transform in shapely format.

Returns: np.array: the transform in numpy matrix format.