weavingspace.topology

Classes for working with topology of tilings.

Together the Topology, Tile, Vertex, Edge, and weavingspace.symmetry.Symmetries classes enable extraction of the topological structure of periodic weavingspace.tileable.Tileable objects so that modification of equivalent tiles can be carried out while retaining tileability.

It is important to note that these are not fully generalised classes and methods, that is, the Topology object that is supported is not a permanent 'backing' data structure for our Tileable objects. While it might become that in time, it is not yet such a data structure. Instead usage is

tile = TileUnit(...)
topology = Topology(tile)
topology = topology.transform_*(...)
new_tile = topology.tile_unit

A Topology plot function is provided for a user to be able to see what they are doing, because how edges and vertices in a tiling are labelled under tile equivalences is an essential step in the process.

Note that these classes do not precisely represent the distinctions made in the mathematical literature between tiling vertices and tile corners, or between tiling edges and tile sides.

   1"""Classes for working with topology of tilings.
   2
   3Together the `Topology`, `Tile`, `Vertex`, `Edge`, and
   4`weavingspace.symmetry.Symmetries` classes enable extraction of the topological
   5structure of periodic `weavingspace.tileable.Tileable` objects so that
   6modification of equivalent tiles can be carried out while retaining
   7tileability.
   8
   9It is important to note that these are not fully generalised classes and
  10methods, that is, the Topology object that is supported is not a permanent
  11'backing' data structure for our Tileable objects. While it might become that
  12in time, it is not yet such a data structure. Instead usage is
  13
  14    tile = TileUnit(...)
  15    topology = Topology(tile)
  16    topology = topology.transform_*(...)
  17    new_tile = topology.tile_unit
  18
  19A Topology plot function is provided for a user to be able to see what they are
  20doing, because how edges and vertices in a tiling are labelled under tile
  21equivalences is an essential step in the process.
  22
  23Note that these classes do not precisely represent the distinctions made in the
  24mathematical literature between tiling vertices and tile corners, or between
  25tiling edges and tile sides.
  26"""
  27from __future__ import annotations
  28
  29import inspect
  30import itertools
  31import pickle
  32import string
  33from collections import defaultdict
  34from typing import TYPE_CHECKING
  35
  36import geopandas as gpd
  37import matplotlib.pyplot as plt
  38import networkx as nx
  39import numpy as np
  40import shapely.affinity as affine
  41import shapely.geometry as geom
  42from scipy import interpolate
  43
  44from weavingspace import (
  45  ShapeMatcher,
  46  Symmetries,
  47  Tileable,
  48  Transform,
  49  tiling_utils,
  50)
  51
  52if TYPE_CHECKING:
  53  from collections.abc import Callable, Iterable
  54
  55# all two letter combinations of the alphabet for labelling
  56LABELS = \
  57  list(string.ascii_letters.upper()) + \
  58  ["".join(x) for x in
  59   itertools.combinations(list(string.ascii_letters.upper()), 2)]
  60labels = \
  61  list(string.ascii_letters.lower()) + \
  62  ["".join(x) for x in
  63   itertools.combinations(list(string.ascii_letters.lower()), 2)]
  64
  65
  66class Topology:
  67  """Class to represent topology of a Tileable object.
  68
  69  NOTE: It is important that get_local_patch return the tileable elements and
  70  the translated copies in consistent sequence, i.e. if there are (say) four
  71  tiles in the unit, the local patch should be 1 2 3 4 1 2 3 4 1 2 3 4 ... and
  72  so on. This is because self.tiles[i % n_tiles] is frequently used to
  73  reference the base unit Tile which corresponds to self.tiles[i].
  74  """
  75
  76  tileable: Tileable
  77  """the Tileable on which the topology will be based."""
  78  tiles: list[Tile]
  79  """list of the Tiles in the topology. We use polygons returned by the
  80  tileable.get_local_patch method for these. That is the base tiles and 8
  81  adjacent copies (for a rectangular tiling), or 6 adjacent copies (for a
  82  hexagonal tiling)."""
  83  points: dict[int, Vertex]
  84  """dictionary of all points (vertices and corners) in the tiling, keyed by
  85  Vertex ID."""
  86  edges: dict[tuple[int,int], Edge]
  87  """dictionary of the tiling edges, keyed by Edge ID."""
  88  unique_tile_shapes: list[geom.Polygon]
  89  """a 'reference' tile shape one per tile shape (up to vertices, so two tiles
  90  might be the same shape, but one might have extra vertices induced by the
  91  tiling and hence is a different shape under this definition)."""
  92  dual_tiles: list[geom.Polygon]
  93  """list of geom.Polygons from which a dual tiling might be constructed."""
  94  n_tiles: int = 0
  95  """number of tiles in the base Tileable (retained for convenience)."""
  96  shape_groups: list[list[int]]
  97  """list of lists of tile IDs distinguished by shape and optionally tile_id"""
  98  tile_matching_transforms: list[tuple[float]]
  99  """shapely transform tuples that map tiles onto other tiles"""
 100  tile_transitivity_classes: list[tuple[int]]
 101  """list of lists of tile IDs in each transitivity class"""
 102  vertex_transitivity_classes: list[list[int]]
 103  """list of lists of vertex IDs in each transitivity class"""
 104  edge_transitivity_classes: list[list[tuple[int]]]
 105  """list of lists of edge IDs in each transitivity class"""
 106
 107  def __init__(
 108      self,
 109      unit: Tileable,
 110      ignore_tile_ids:bool = True,
 111    ) -> None:
 112    """Class constructor.
 113
 114    Args:
 115      unit (Tileable): the Tileable whose topology is required.
 116      ignore_tile_ids (bool): (EXPERIMENTAL) if True then only consider the tile
 117        shapes, not labels. If False consider any labels. Defaults to True.
 118
 119    """
 120    # Note that the order of these setup steps is fragile sometimes
 121    # not obviously so.
 122    self.tileable = unit # keep this for reference
 123    self.n_tiles = self.tileable.tiles.shape[0]
 124    self._initialise_points_into_tiles()
 125    self._setup_vertex_tile_relations()
 126    self._setup_edges()
 127    self._copy_base_tiles_to_patch()
 128    self._assign_vertex_and_edge_base_IDs()
 129    self._identify_distinct_tile_shapes(ignore_tile_ids)
 130    self._find_tile_transitivity_classes(ignore_tile_ids)
 131    self._find_vertex_transitivity_classes(ignore_tile_ids)
 132    self._find_edge_transitivity_classes(ignore_tile_ids)
 133    self.generate_dual()
 134
 135
 136  def __str__(self) -> str:
 137    """Return string representation of this Topology.
 138
 139    Returns:
 140      str: a message that recommends examining the tiles, points and edges
 141        attributes.
 142
 143    """
 144    return (f"""Topology of Tileable with {self.n_tiles} tiles.\n
 145            Examine .tiles, .points and .edges for more details.""")
 146
 147
 148  def __repr__(self) -> str:
 149    return str(self)
 150
 151
 152  def _initialise_points_into_tiles(self, debug:bool = False) -> None:
 153    """Set up dictionary of unique point locations and assign them to Tiles.
 154
 155    Args:
 156      debug (bool): if True prints useful debugging information.
 157
 158    """
 159    shapes = self.tileable.get_local_patch(r = 1, include_0 = True).geometry
 160    shapes = [tiling_utils.get_clean_polygon(s) for s in shapes]
 161    self.n_1_order_patch = len(shapes)
 162    labels = list(self.tileable.tiles.tile_id) * (len(shapes) // self.n_tiles)
 163    self.tiles = []
 164    self.points = {}
 165    for (i, shape), label in zip(enumerate(shapes), labels, strict = True):
 166      tile = Tile(i)
 167      tile.label = label
 168      tile.base_ID = tile.ID % self.n_tiles
 169      self.tiles.append(tile)
 170      tile.corners = []
 171      corners = tiling_utils.get_corners(shape, repeat_first = False)
 172      for c in corners:
 173        prev_vertex = None
 174        for p in reversed(self.points.values()):
 175          if c.distance(p.point) <= 2 * tiling_utils.RESOLUTION:
 176            # found an already existing vertex, so add to the tile and break
 177            prev_vertex = p
 178            tile.corners.append(prev_vertex)
 179            break
 180        if prev_vertex is None:
 181          # new vertex, add it to the topology dictionary and to the tile
 182          v = self.add_vertex(c)
 183          tile.corners.append(v)
 184          if debug:
 185            print(f"Added new Vertex {v} to Tile {i}")
 186
 187
 188  def _setup_vertex_tile_relations(self, debug:bool = False) -> None:
 189    """Determine relations between vertices and tiles.
 190
 191    In particular vertices along tile edges that are not yet included in their
 192    list of vertices are added. Meanwhile vertex lists of incident tiles and
 193    neighbours are set up.
 194
 195    Args:
 196      debug (bool): if True prints debugging information.
 197
 198    """
 199    # we do this for all tiles in the radius-1 local patch
 200    for tile in self.tiles:
 201      if debug:
 202        print(f"Checking for vertices incident on Tile {tile.ID}")
 203      corners = []
 204      # performance is much improved using the vertex IDs
 205      initial_corner_IDs = tile.get_corner_IDs()
 206      # we need the current shape (not yet set) to check for incident vertices
 207      shape = geom.Polygon([c.point for c in tile.corners])
 208      # get points incident on tile boundary, not already in the tile corners
 209      new_points = [v for v in self.points.values()
 210                    if v.ID not in initial_corner_IDs and
 211                    v.point.distance(shape) <= 2 * tiling_utils.RESOLUTION]
 212      # iterate over sides of the tile to see which the vertex is incident on
 213      for c1, c2 in zip(tile.corners, tile.corners[1:] + tile.corners[:1],
 214                        strict = True):
 215        all_points = [c1, c2]
 216        if len(new_points) > 0:
 217          if debug:
 218            print(f"{[v.ID for v in new_points]} incident on tile")
 219          ls = geom.LineString([c1.point, c2.point])
 220          to_insert = [v for v in new_points
 221                       if v.point.distance(ls) <= 2 * tiling_utils.RESOLUTION]
 222          if len(to_insert) > 0:
 223            # sort by distance along the side
 224            d_along = sorted([(ls.line_locate_point(v.point), v)
 225                              for v in to_insert], key = lambda x: x[0])
 226            to_insert = [v for d, v in d_along]
 227            all_points = all_points[:1] + to_insert + all_points[1:]
 228        corners.extend(all_points[:-1])
 229        for x1, x2 in zip(all_points[:-1], all_points[1:], strict = True):
 230          # x2 will add the tile and neigbour when we get to the next side
 231          # every vertex gets a turn!
 232          x1.add_tile(tile)
 233          x1.add_neighbour(x2.ID)
 234      tile.corners = corners
 235      tile.set_shape_from_corners()
 236
 237
 238  def _setup_edges(
 239      self,
 240      debug:bool = False,
 241    ) -> None:
 242    """Set up the tiling edges.
 243
 244    First vertices in the base tiles are classified as tiling vertices or not -
 245    only these can be classified reliably (e.g vertices on the perimeter are
 246    tricky).. Up to here all vertices have been considered tiling vertices.
 247
 248    Second edges are created by traversing tile corner lists. Edges are stored
 249    once only by checking for edges in the reverse direction already in the
 250    edges dictionary. Edge right and left tiles are initialised.
 251
 252    Third tile edge direction lists are initialised.
 253
 254    Args:
 255      debug (bool): if True print debug messages. Defaults to False.
 256
 257    """
 258    # classify vertices in the base tiles
 259    for tile in self.tiles[:self.n_tiles]:
 260      for v in tile.corners:
 261        v.is_tiling_vertex = len(v.neighbours) > 2
 262    if debug:
 263      print("Classified base tile vertices")
 264    self.edges = {}
 265    for tile in self.tiles:
 266      if debug:
 267        print(f"Adding edges from Tile {tile.ID}")
 268      tile.edges = []
 269      vertices = [v for v in tile.corners if v.is_tiling_vertex]
 270      # note that through here finding ints in lists is much faster than
 271      # finding Vertex objects, hence we use lists of IDs not Vertex objects
 272      if len(vertices) > 1:
 273        for v1, v2 in zip(vertices, vertices[1:] + vertices[:1], strict = True):
 274          corner_IDs = tile.get_corner_IDs()
 275          idx1 = corner_IDs.index(v1.ID)
 276          idx2 = corner_IDs.index(v2.ID)
 277          if idx1 < idx2:
 278            corners = corner_IDs[idx1:(idx2 + 1)]
 279          else:
 280            corners = corner_IDs[idx1:] + corner_IDs[:(idx2 + 1)]
 281          ID = (corners[0], corners[-1])
 282          if ID not in self.edges:
 283            # check that reverse direction edge is not present first
 284            r_ID = ID[::-1]
 285            if r_ID in self.edges:
 286              # if it is, then add it and set left_tile
 287              if debug:
 288                print(f"reverse edge {r_ID} found")
 289              e = self.edges[r_ID]
 290              e.left_tile = tile
 291              tile.edges.append(e)
 292            else:
 293              # we've found a new edge so make and add it
 294              if debug:
 295                print(f"adding new edge {corners}")
 296              e = self.add_edge([self.points[c] for c in corners])
 297              e.right_tile = tile
 298              tile.edges.append(e)
 299      # initialise the edge direction information in the tile
 300      tile.set_edge_directions()
 301
 302
 303  def _assign_vertex_and_edge_base_IDs(self) -> None:
 304    """Assign the base_ID attributes of vertices and edges.
 305
 306    These allow us to determine corrspondences between vertices and edges in
 307    the 'base' tiles in the Topology tileable, and those we have added at
 308    radius 1 for labelling and visualisation.
 309    """
 310    self._assign_vertex_base_IDs()
 311    self._assign_edge_base_IDs()
 312
 313
 314  def _assign_vertex_base_IDs(self) -> None:
 315    """Assign base_ID attribute of vertices."""
 316    # assign vertex base_ID frome the core tiles
 317    for tile0 in self.tiles[:self.n_tiles]:
 318      for v in tile0.get_corner_IDs():
 319        self.points[v].base_ID = v
 320    # assign others from their corresponding vertex in the core
 321    for t0 in self.tiles[:self.n_tiles]:
 322      for t1 in self.tiles[self.n_tiles:]:
 323        if t1.base_ID == t0.base_ID:
 324          for v0, v1 in zip(t0.corners, t1.corners, strict = True):
 325            v1.base_ID = v0.base_ID
 326
 327
 328  def _assign_edge_base_IDs(self) -> None:
 329    """Assign base_ID attribute of edges, based on base_IDs of vertices."""
 330    for tile0 in self.tiles[:self.n_tiles]:
 331      for e in tile0.get_edge_IDs():
 332        self.edges[e].base_ID = e
 333    for t0 in self.tiles[:self.n_tiles]:
 334      for t1 in self.tiles[self.n_tiles:]:
 335        if t1.base_ID == t0.base_ID:
 336          for e0, e1 in zip(t0.edges, t1.edges, strict = True):
 337            e1.base_ID = e0.base_ID
 338
 339
 340  def _copy_base_tiles_to_patch(self) -> None:
 341    """Copy attributes of base tiles to corresponding tiles in radius-1 patch.
 342
 343    This requires:
 344
 345    First, inserting any additional corners in the base tiles not found in the
 346    radius-1 tiles.
 347
 348    Second, any vertices in the base tiles that are NOT tiling vertices are
 349    applied to radius-1 tiles leading to merging of some edges.
 350    """
 351    # the number of tiles in the base + radius-1
 352    n_r1 = len(self.tiles)
 353    # first add any missing vertices to the non-base tiles
 354    # we add all the missing vertices before doing any merges
 355    for base in self.tiles[:self.n_tiles]:
 356      for other in self.tiles[base.ID:n_r1:self.n_tiles]:
 357        self._match_reference_tile_vertices(base, other)
 358    # then merge any edges that meet at a corner
 359    for base in self.tiles[:self.n_tiles]:
 360      for other in self.tiles[base.ID:n_r1:self.n_tiles]:
 361        self._match_reference_tile_corners(base, other)
 362
 363
 364  def _match_reference_tile_vertices(
 365      self,
 366      tile1:Tile,
 367      tile2:Tile,
 368    ) -> None:
 369    """Add vertices to tile2 so it matches tile1 adjusting edges as required.
 370
 371    This assumes the tiles are the same shape, but that tile2 may be missing
 372    some tiling vertices along some edges.
 373
 374    Args:
 375      tile1 (Tile): reference tile.
 376      tile2 (Tile): tile to change.
 377
 378    """
 379    to_add = len(tile1.corners) - len(tile2.corners)
 380    while to_add > 0:
 381      # find the reference x-y offset
 382      dxy = (tile2.centre.x - tile1.centre.x, tile2.centre.y - tile1.centre.y)
 383      for i, t1c in enumerate([c.point for c in tile1.corners]):
 384        t2c = tile2.corners[i % len(tile2.corners)].point
 385        if abs((t2c.x - t1c.x) - dxy[0]) > 10 * tiling_utils.RESOLUTION or \
 386           abs((t2c.y - t1c.y) - dxy[1]) > 10 * tiling_utils.RESOLUTION:
 387          # add vertex to t2 by copying the t1 vertex appropriately offset
 388          # note that this might alter the length of t2.corners
 389          v = self.add_vertex(geom.Point(t1c.x + dxy[0], t1c.y + dxy[1]))
 390          v.is_tiling_vertex = True
 391          old_edge, new_edges = tile2.insert_vertex_at(v, i)
 392          del self.edges[old_edge]
 393          for e in new_edges:
 394            self.edges[e.ID] = e
 395          to_add = to_add - 1
 396
 397
 398  def _match_reference_tile_corners(
 399      self,
 400      tile1:Tile,
 401      tile2:Tile,
 402    ) -> None:
 403    """Make vertices that are corners in tile1 corners in tile2.
 404
 405    Edges are merged as required.
 406
 407    Args:
 408        tile1 (Tile): reference tile.
 409        tile2 (Tile): tile to make match.
 410
 411    """
 412    vs_to_change = [
 413      vj for vi, vj in zip(tile1.corners, tile2.corners, strict = True)
 414      if not vi.is_tiling_vertex and vj.is_tiling_vertex]
 415    if len(vs_to_change) > 0:
 416      for v in vs_to_change:
 417        v.is_tiling_vertex = False
 418        # it's a corner not an edge so will have no more than 2 v.tiles
 419        old_edges, new_edge = v.tiles[0].merge_edges_at_vertex(v)
 420        for e in old_edges:
 421          del self.edges[e]
 422        self.edges[new_edge.ID] = new_edge
 423
 424  def _identify_distinct_tile_shapes(
 425      self,
 426      ignore_tile_id_labels:bool = True,
 427    ) -> None:
 428    """Identify unique tiles based on their symmetries and shapes.
 429
 430    At the same time assembles a list of the affine transforms under which
 431    matches occurs since these are potential symmetries of the tiling.
 432
 433    TODO: reimplement consideration of tile_id
 434
 435    Args:
 436      ignore_tile_id_labels (bool): if True only the shape of tiles matters; if
 437        False the tile_id label is also considered. Defaults to True.
 438
 439    """
 440    if ignore_tile_id_labels:
 441      matches = {}
 442      offsets = {}
 443      for tile in self.tiles[:self.n_tiles]:
 444        matches[tile.base_ID] = [tile.base_ID]
 445        matched = False
 446        s = Symmetries(tile.shape)
 447        for other in self.tiles[:self.n_tiles]:
 448          if other.ID > tile.ID:
 449            offset = s.get_corner_offset(other.shape)
 450            if offset is not None:
 451              offsets[tile.base_ID] = offset
 452              matches[tile.base_ID].append(other.base_ID)
 453              matched = True
 454        if not matched:
 455          offsets[tile.base_ID] = 0
 456      base_groups = list(
 457        nx.connected_components(nx.from_dict_of_lists(matches)))
 458      self.shape_groups = []
 459      for i, group in enumerate(base_groups):
 460        full_group = []
 461        for tile in self.tiles:
 462          if tile.base_ID in group:
 463            tile.shape_group = i
 464            tile.offset_corners(offsets[tile.base_ID])
 465            full_group.append(tile.ID)
 466        self.shape_groups.append(full_group)
 467    else:
 468      self.shape_groups = []
 469      for ti in self.tiles[:self.n_tiles]:
 470        self.shape_groups.append(
 471          [tj.ID for tj in self.tiles if tj.base_ID == ti.base_ID])
 472      for i, group in enumerate(self.shape_groups):
 473        for j in group:
 474          self.tiles[j].shape_group = i
 475
 476  def _find_tile_transitivity_classes(
 477      self,
 478      ignore_tile_id_labels:bool = True,
 479    ) -> None:
 480    """Find tiles equivalent under symmetries.
 481
 482    Also update the tile_matching_transforms attribute to contain only those
 483    transforms that pass this test.
 484
 485    Args:
 486      ignore_tile_id_labels (bool): if True then consider only shapes; if False
 487        also consider labels of tiles. Defaults to True.
 488
 489    """
 490    self.tile_matching_transforms = \
 491      self.get_potential_symmetries(ignore_tile_id_labels)
 492    if ignore_tile_id_labels:
 493      base_tiles = self.tiles[:self.n_tiles]
 494      # it is quicker (should be!) to only do within shape group tests
 495      # often there is only one when it will make no difference
 496      by_group_equivalent_tiles = []
 497      # maintain a set of transforms still potentially tiling symmetries
 498      poss_transforms = set(self.tile_matching_transforms.keys())
 499      # and a dictionary of booleans tracking which transforms are still valid
 500      eq_under_transform = dict.fromkeys(poss_transforms, True)
 501      for g, _ in enumerate(self.shape_groups):
 502        by_group_equivalent_tiles.append(set())
 503        source_tiles = [tile for tile in base_tiles if tile.shape_group == g]
 504        target_tiles = [tile for tile in self.tiles if tile.shape_group == g]
 505        for tr in poss_transforms:
 506          transform = self.tile_matching_transforms[tr].transform
 507          matched_tiles = {}
 508          eq_under_transform[tr] = True
 509          for source_tile in source_tiles:
 510            matched_tile_id = self._match_geoms_under_transform(
 511              source_tile, target_tiles, transform)
 512            if matched_tile_id == -1:
 513              eq_under_transform[tr] = False
 514              break
 515            matched_tiles[source_tile.ID] = matched_tile_id # actually a base_ID
 516          if eq_under_transform[tr]:
 517            for k, v in matched_tiles.items():
 518              # here we record the transform, in case it is later invalidated
 519              by_group_equivalent_tiles[g].add((tr, k, v))
 520        # remove valid transforms that didn't make it through this group
 521        poss_transforms = {t for t, x in eq_under_transform.items() if x}
 522      # compile equivalences from all groups made under still valid transforms
 523      # a dict of sets so singletons aren't lost in finding connected components
 524      equivalents = {i: set() for i in range(self.n_tiles)}
 525      for group_equivalents in by_group_equivalent_tiles:
 526        for (tr, tile_i, tile_j) in group_equivalents:
 527          if tr in poss_transforms:
 528            equivalents[tile_i].add(tile_j)
 529      self.tile_matching_transforms = {
 530        k: v for k, v in self.tile_matching_transforms.items()
 531        if k in poss_transforms}
 532      self.tile_transitivity_classes = []
 533      equivalents = nx.connected_components(nx.from_dict_of_lists(equivalents))
 534      for c, base_IDs in enumerate(equivalents):
 535        transitivity_class = []
 536        for tile in self.tiles:
 537          if tile.base_ID in base_IDs:
 538            transitivity_class.append(tile.ID)
 539            tile.transitivity_class = c
 540        self.tile_transitivity_classes.append(transitivity_class)
 541    else:
 542      # transitivity classes are just the individual tiles
 543      self.tile_transitivity_classes = []
 544      for i, tile in enumerate(self.tiles):
 545        tile.transitivity_class = tile.base_ID
 546        if i < self.n_tiles:
 547          self.tile_transitivity_classes.append([tile.ID])
 548        else:
 549          self.tile_transitivity_classes[tile.base_ID].append(tile.ID)
 550
 551
 552  def get_potential_symmetries(
 553      self,
 554      ignore_tile_id_labels:bool = True,
 555    ) -> dict[int, Transform]:
 556    """Assemble potential symmetries from symmetries of prototile and tiles.
 557
 558    Also remove any duplicates that result. The result is assigned to the
 559    tile_matching_transforms attribute.
 560
 561    TODO: consider retaining the Symmetry objects as these carry additional
 562    information that might facilitate labelling under a limited number of the
 563    symmetries not all of them.
 564
 565    Returns:
 566      dict[int, tuple[float]]: dictionary of the symmetries (transforms
 567        actually) in shapely affine transform 6-tuple format.
 568
 569    """
 570    self.tile_matching_transforms = {
 571      k: Transform("translation", 0, geom.Point(0, 0), v,
 572                   tiling_utils.get_translation_transform(v[0], v[1]))
 573      for k, v in enumerate(self.tileable.get_vectors())}
 574    if ignore_tile_id_labels:
 575      n_symmetries = len(self.tile_matching_transforms)
 576      ptile = self.tileable.prototile.loc[0, "geometry"]
 577      for tr in ShapeMatcher(ptile).get_polygon_matches(ptile):
 578        if tr.transform_type not in ["identity", "translation"]:
 579          self.tile_matching_transforms[n_symmetries] = tr
 580          n_symmetries = n_symmetries + 1
 581      for tile in self.tiles[:self.n_tiles]:
 582        for tr in ShapeMatcher(tile.shape).get_polygon_matches(tile.shape):
 583          if tr.transform_type not in ["identity", "translation"]:
 584            self.tile_matching_transforms[n_symmetries] = tr
 585            n_symmetries = n_symmetries + 1
 586      for tile in self.tiles[:self.n_tiles]:
 587        sm = ShapeMatcher(tile.shape)
 588        transforms = [sm.get_polygon_matches(self.tiles[i].shape)
 589          for i in self.shape_groups[tile.shape_group] if i < self.n_tiles]
 590        for tr in itertools.chain(*transforms):
 591          if tr.transform_type not in ["identity", "translation"]:
 592            self.tile_matching_transforms[n_symmetries] = tr
 593            n_symmetries = n_symmetries + 1
 594      self.tile_matching_transforms = self._remove_duplicate_symmetries(
 595        self.tile_matching_transforms)
 596    return self.tile_matching_transforms
 597
 598
 599  def _remove_duplicate_symmetries(
 600      self,
 601      transforms:dict[int,Transform],
 602    ) -> dict[int,Transform]:
 603    """Filter list of shapely affine transforms to remove duplicates.
 604
 605    Args:
 606      transforms (dict[int,Transform]): dictionary of Transforms to filter.
 607
 608    Returns:
 609      dict[int,Transform]: the filtered dictionary with duplicates removed.
 610
 611    """
 612    uniques = {}
 613    for k, v in transforms.items():
 614      already_exists = False
 615      for u in uniques.values():
 616        already_exists = np.allclose(
 617          v.transform, u.transform, atol = 1e-4, rtol = 1e-4)
 618        if already_exists:
 619          break
 620      if not already_exists:
 621        uniques[k] = v
 622    return uniques
 623
 624
 625  def _find_vertex_transitivity_classes(
 626      self,
 627      ignore_tile_id_labels:bool = True,
 628    ) -> None:
 629    """Find vertex transitivity classes.
 630
 631    This function checks which vertices align with which others under transforms
 632    in the tile_matching_transforms attribute. The process need only determine
 633    the classes for vertices in the core tileable.tiles, then assign those to
 634    all vertices by matched base_ID.
 635    """
 636    if ignore_tile_id_labels:
 637      equivalent_vertices = defaultdict(set)
 638      base_vertices = [v for v in
 639                      self.vertices_in_tiles(self.tiles[:self.n_tiles])
 640                      if v.is_tiling_vertex]
 641      for transform in self.tile_matching_transforms.values():
 642        for v in base_vertices:
 643          equivalent_vertices[v.ID].add(v.ID)
 644          match_ID = self._match_geoms_under_transform(
 645            v, base_vertices, transform.transform)
 646          if match_ID != -1:
 647            equivalent_vertices[v.ID].add(match_ID)
 648      equivalent_vertices = self._get_exclusive_supersets(
 649        [tuple(sorted(s)) for s in equivalent_vertices.values()])
 650      self.vertex_transitivity_classes = defaultdict(list)
 651      for c, vclass in enumerate(equivalent_vertices):
 652        for v in self.points.values():
 653          if v.base_ID in vclass:
 654            v.transitivity_class = c
 655            self.vertex_transitivity_classes[c].append(v.ID)
 656      self.vertex_transitivity_classes = list(
 657        self.vertex_transitivity_classes.values())
 658      # label vertices based on their transitivity class
 659      for v in self.points.values():
 660        if v.is_tiling_vertex:
 661          v.label = LABELS[v.transitivity_class]
 662    else:
 663      self.vertex_transitivity_classes = defaultdict(list)
 664      for v in self.points.values():
 665        if v.is_tiling_vertex:
 666          self.vertex_transitivity_classes[v.base_ID].append(v.ID)
 667          v.transitivity_class = v.base_ID
 668      self.vertex_transitivity_classes = list(
 669        self.vertex_transitivity_classes.values())
 670      for v in self.points.values():
 671        if v.is_tiling_vertex:
 672          v.label = LABELS[v.transitivity_class]
 673
 674
 675  def _find_edge_transitivity_classes(
 676      self,
 677      ignore_tile_id_labels:bool = True,
 678    ) -> None:
 679    """Find edge transitivity classes.
 680
 681    This function works by checking which edges align with which others under
 682    transforms in the tile_matching_transforms attribute. The process need only
 683    determine the classes for edges in the core tileable.tiles, then assign
 684    those to all edges by matched base_ID.
 685
 686    TODO: Note that this code is identical to the vertex transitivity code
 687    so it might make sense to merge.
 688    """
 689    if ignore_tile_id_labels:
 690      equivalent_edges = defaultdict(set)
 691      base_edges = self.edges_in_tiles(self.tiles[:self.n_tiles])
 692      for transform in self.tile_matching_transforms.values():
 693        for e in base_edges:
 694          equivalent_edges[e.ID].add(e.ID)
 695          match_id = self._match_geoms_under_transform(
 696            e, base_edges, transform.transform)
 697          if match_id != -1:
 698            equivalent_edges[e.ID].add(match_id)
 699      equivalent_edges = self._get_exclusive_supersets(
 700        [tuple(sorted(s)) for s in equivalent_edges.values()])
 701      self.edge_transitivity_classes = defaultdict(list)
 702      for c, eclass in enumerate(equivalent_edges):
 703        for e in self.edges.values():
 704          if e.base_ID in eclass:
 705            e.transitivity_class = c
 706            self.edge_transitivity_classes[c].append(e.ID)
 707      self.edge_transitivity_classes = list(
 708        self.edge_transitivity_classes.values())
 709      # label edges based on their transitivity class
 710      for e in self.edges.values():
 711        e.label = labels[e.transitivity_class]
 712    else:
 713      self.edge_transitivity_classes = defaultdict(list)
 714      for e in self.edges.values():
 715        self.edge_transitivity_classes[e.base_ID].append(e.ID)
 716      self.edge_transitivity_classes = list(
 717        self.edge_transitivity_classes.values())
 718      for i, eclass in enumerate(self.edge_transitivity_classes):
 719        for e in eclass:
 720          self.edges[e].transitivity_class = i
 721          self.edges[e].label = labels[i]
 722
 723
 724  def _match_geoms_under_transform(
 725      self,
 726      geom1:Tile|Vertex|Edge,
 727      geoms2:list[Tile|Vertex|Edge],
 728      transform:tuple[float,...],
 729    ) -> int|tuple[int]:
 730    """Determine if a geometry maps onto any in a patch under supplied symmetry.
 731
 732    Args:
 733      geom1 (Tile|Vertex|Edge): element whose geometry we want to match.
 734      geoms2 (list[Tile|Vertex|Edge]): set of elements among which a
 735        match is sought.
 736      transform (tuple[float]): shapely affine transform 6-tuple to apply.
 737
 738    Returns:
 739      int|tuple[int,int]: ID of the element in patch that matches the geom1
 740        element under the transform if one exists, otherwise returns -1. For
 741        edges note that the ID is a tuple.
 742
 743    """
 744    match_id = -1
 745    for geom2 in geoms2:
 746      if isinstance(geom1, Tile):
 747        # an area of intersection based test
 748        match = self.polygon_matches(
 749          affine.affine_transform(geom1.shape, transform), geom2.shape)
 750      elif isinstance(geom1, Vertex):
 751        # distance test
 752        match = affine.affine_transform(geom1.point, transform).distance(
 753          geom2.point) <= 10 * tiling_utils.RESOLUTION
 754      else: # must be an Edge
 755        # since edges _should not_ intersect this test should work in
 756        # lieu of a more complete point by point comparison
 757        c1 = geom1.get_geometry().centroid
 758        c2 = geom2.get_geometry().centroid
 759        match = affine.affine_transform(c1, transform) \
 760          .distance(c2) <= 10 *tiling_utils.RESOLUTION
 761      if match:
 762        return geom2.base_ID
 763    return match_id
 764
 765
 766  def _get_exclusive_supersets(
 767      self, sets:list[Iterable]) -> list[Iterable]:
 768    """Return sets of elements not found in the same set among those supplied.
 769
 770    The supplied sets share elements, i.e., they are non-exclusives sets. The
 771    returned sets are exclusive: each element will only appear in one of the
 772    sets in the returned list. This is accomplished using networkx's
 773    connected components applied to a graph where each intersection between two
 774    sets is an edge.
 775
 776    Args:
 777        sets (list[Iterable]): list of lists of possibly overlapping sets.
 778
 779    Returns:
 780      list[Iterable]: list of lists that include all the original
 781        elements without overlaps.
 782
 783    """
 784    overlaps = []
 785    for i, si in enumerate(sets):
 786      s1 = set(si)
 787      for j, sj in enumerate(sets):
 788        s2 = set(sj)
 789        if len(s1 & s2) > 0:
 790          overlaps.append((i, j))
 791    G = nx.from_edgelist(overlaps)
 792    result = []
 793    for component in nx.connected_components(G):
 794      s = set()
 795      for i in component:
 796        s = s.union(sets[i])
 797      result.append(tuple(s))
 798    return result
 799
 800
 801  def vertices_in_tiles(self, tiles:list[Tile]) -> list[Vertex]:
 802    """Get vertices incident on tiles in supplied list.
 803
 804    Args:
 805      tiles (list[Tile]): tiles whose vertices are required.
 806
 807    Returns:
 808      list[Vertex]: the required vertices.
 809
 810    """
 811    vs = set()
 812    for tile in tiles:
 813      vs = vs.union(tile.get_corner_IDs())
 814    return [self.points[v] for v in vs]
 815
 816
 817  def edges_in_tiles(self, tiles:list[Tile]) -> list[Edge]:
 818    """Get edges that are part of the boundary of tiles in supplied list.
 819
 820    Args:
 821      tiles (list[Tile]): tiles whose edges are required.
 822
 823    Returns:
 824      list[Edge]: the required edges.
 825
 826    """
 827    es = set()
 828    for tile in tiles:
 829      es = es.union(tile.get_edge_IDs())
 830    return [self.edges[e] for e in es]
 831
 832
 833  def generate_dual(self) -> list[geom.Polygon]:
 834    """Create the dual tiiing for the tiling of this Topology.
 835
 836    TODO: make this a viable replacement for the existing dual tiling
 837    generation.
 838
 839    TODO: also need to ensure that this finds a set of dual tiles that exhaust
 840    the plane...
 841
 842    Returns:
 843      list[geom.Polygon]: a list of polygon objects.
 844
 845    """
 846    for v in self.points.values():
 847      v.clockwise_order_incident_tiles()
 848    self.dual_tiles = {}
 849    base_id_sets = defaultdict(list)
 850    for v in self.points.values():
 851      base_id_sets[v.base_ID].append(v.ID)
 852    minimal_set = [self.points[min(s)] for s in base_id_sets.values()]
 853    for v in minimal_set:
 854    # for v in self.points.values():
 855      if v.is_interior() and len(v.tiles) > 2:
 856        self.dual_tiles[v.ID] = \
 857          geom.Polygon([t.centre for t in v.tiles])
 858
 859
 860  def get_dual_tiles(self) -> gpd.GeoDataFrame:
 861    """Return dual tiles as GeoDataFrame."""
 862    n = len(self.dual_tiles)
 863    return gpd.GeoDataFrame(
 864      data = {"tile_id": list(self.tileable.tiles.tile_id)[:n]},
 865      geometry = gpd.GeoSeries(self.dual_tiles.values()),
 866      crs = self.tileable.crs)
 867
 868
 869  def add_vertex(self, pt:geom.Point) -> Vertex:
 870    """Add and return Vertex at the specified point location.
 871
 872    No attempt is made to ensure Vertex IDs are an unbroken sequence: a new ID
 873    is generated one greater than the existing highest ID. IDs will usually be
 874    an unbroken sequence up to removals when geometry transformations are
 875    applied.
 876
 877    Args:
 878      pt (geom.Point): point location of the Vertex.
 879
 880    Returns:
 881      Vertex: the added Vertex object.
 882
 883    """
 884    n = 0 if len(self.points) == 0 else max(self.points.keys()) + 1
 885    v = Vertex(pt, n)
 886    self.points[n] = v
 887    return v
 888
 889
 890  def add_edge(self, vs:list[Vertex]) -> Edge:
 891    """Create an Edge from the suppled vertices and return it.
 892
 893    The new Edge is added to the edges dictionary. Edges are self indexing by
 894    the IDs of their end Vertices.
 895
 896    Args:
 897      vs (list[Vertex]): list of Vertices in the Edge to be created.
 898
 899    Returns:
 900        Edge: the added Edge.
 901
 902    """
 903    e = Edge(vs)
 904    self.edges[e.ID] = e
 905    return e
 906
 907
 908  def polygon_matches(
 909      self,
 910      geom1:geom.Polygon,
 911      geom2:geom.Polygon,
 912    ) -> bool:
 913    """Test if supplied polygons match geometrically.
 914
 915    Tests for equality of area, and equality of their area of overlap to their
 916    shared area, i.e. Area1 == Area2 == (Area 1 intersection 2).
 917
 918    Args:
 919      geom1 (geom.Polygon): first polygon.
 920      geom2 (geom.Polygon): second polygon.
 921
 922    Returns:
 923      bool: True if the polygons are the same, False otherwise.
 924
 925    """
 926    a, b = geom1.area, geom2.area
 927    return bool(
 928      np.isclose(a, b,
 929                 rtol = tiling_utils.RESOLUTION * 100,
 930                 atol = tiling_utils.RESOLUTION * 100) and
 931      np.isclose(a, geom1.intersection(geom2).area,
 932                 rtol = tiling_utils.RESOLUTION * 100,
 933                 atol = tiling_utils.RESOLUTION * 100))
 934
 935
 936  def _get_tile_geoms(self) -> gpd.GeoDataFrame:
 937    """Return GeoDataFrame of the tiles.
 938
 939    Returns:
 940      gpd.GeoDataFrame: the tiles as a GeoDataFrame.
 941
 942    """
 943    return gpd.GeoDataFrame(
 944      data = {"transitivity_class": [t.transitivity_class for t in self.tiles]},
 945      geometry = gpd.GeoSeries([t.shape for t in self.tiles]))
 946
 947
 948  def _get_tile_centre_geoms(self) -> gpd.GeoSeries:
 949    """Return the centre of tiles as a GeoDataFrame.
 950
 951    Returns:
 952      gpd.GeoDataFrame: the tiles centres.
 953
 954    """
 955    return gpd.GeoSeries([t.centre for t in self.tiles])
 956
 957
 958  def _get_point_geoms(self) -> gpd.GeoSeries:
 959    """Return tiling vertices and tile corners as a GeoDataFrame of Points.
 960
 961    Returns:
 962      gpd.GeoDataFrame: corners and vertices of the tiling.
 963
 964    """
 965    return gpd.GeoSeries([v.point for v in self.points.values()])
 966
 967
 968  def _get_vertex_geoms(self) -> gpd.GeoSeries:
 969    """Return tiling vertices (not corners) as a GeoDataFrame of Points.
 970
 971    Returns:
 972      gpd.GeoDataFrame: vertices of the tiling.
 973
 974    """
 975    return gpd.GeoSeries([v.point for v in self.points.values()
 976                          if v.is_tiling_vertex])
 977
 978
 979  def _get_edge_geoms(
 980      self,
 981      offset:float = 0.0,
 982    ) -> gpd.GeoSeries:
 983    """Return tiling edges a GeoSeries of LineStrings optionally offset.
 984
 985    Offsetting the edges allows their direction to be seen when visualised.
 986
 987    Returns:
 988      gpd.GeoDataFrame: edges of the tiling.
 989
 990    """
 991    return gpd.GeoSeries([e.get_topology().parallel_offset(offset)
 992                          for e in self.edges.values()])
 993
 994
 995  def plot(
 996      self,
 997      show_original_tiles: bool = True,
 998      show_tile_centres: bool = False,
 999      show_vertex_labels: bool = True,
1000      show_vertex_ids: bool = False,
1001      show_edges: bool = True,
1002      offset_edges: bool = True,
1003      show_edge_labels:bool = False,
1004      show_dual_tiles: bool = False,
1005    ) -> plt.Axes:
1006    """Delegate plotting of requested elements and return plt.Axes.
1007
1008    Args:
1009      show_original_tiles (bool, optional): if True show the tiles. Defaults to
1010        True.
1011      show_tile_centres (bool, optional): if True show tile centres with upper
1012        case alphabetical labels. Defaults to False.
1013      show_vertex_labels (bool, optional): if True show tiling vertices labelled
1014        to show their equivalence classes. Defaults to True.
1015      show_vertex_ids (bool, optional): if True show vertex IDs (i.e., sequence
1016        numbers) which is useful for debugging. Defaults to False.
1017      show_edges (bool, optional): if True show tiling edges (not tile sides).
1018        Defaults to True.
1019      offset_edges (bool, optional): if True offset edges a little from and
1020        parallel to their geometric position. Defaults to True.
1021      show_edge_labels (bool, optional): if true show lower case alphabetical
1022        labels identifying edge equivalence classes. Defaults to False.
1023      show_dual_tiles (bool, optional): if True show a candidate set of dual
1024        tiles as an overlay. Defaults to False.
1025
1026    Returns:
1027      plt.Axes: a plot of the Topology as requested.
1028
1029    """
1030    fig = plt.figure(figsize = (10, 10))
1031    ax = fig.add_axes(111)
1032    extent = gpd.GeoSeries([t.shape for t in self.tiles]).total_bounds
1033    dist = max([extent[2] - extent[0], extent[3] - extent[1]]) / 100
1034    if show_original_tiles:
1035      self._plot_tiles(ax)
1036    if show_tile_centres:
1037      self._plot_tile_centres(ax)
1038    if show_vertex_labels:
1039      self._plot_vertex_labels(ax, show_vertex_ids)
1040    if show_edge_labels or show_edges:
1041      self._plot_edges(ax, show_edges, show_edge_labels, dist, offset_edges)
1042    if show_dual_tiles:
1043      self._plot_dual_tiles(ax, dist)
1044    plt.axis("off")
1045    return ax
1046
1047
1048  def _plot_tiles(self, ax:plt.Axes) -> plt.Axes:
1049    """Plot Topology's tiles on supplied Axes.
1050
1051    Tiles are coloured by equivalence class.
1052
1053    Args:
1054      ax (plt.Axes): Axes on which to plot.
1055
1056    Returns:
1057      plt.Axes: the Axes.
1058
1059    """
1060    self._get_tile_geoms().plot(column = "transitivity_class",
1061      ax = ax, ec = "#444444", lw = 0.5, alpha = 0.25, cmap = "Greys")
1062    return ax
1063
1064
1065  def _plot_tile_centres(self, ax:plt.Axes) -> plt.Axes:
1066    """Print tile transitivity class at each tile centroid.
1067
1068    Args:
1069      ax (plt.Axes): Axes on which to plot.
1070
1071    Returns:
1072      plt.Axes: the Axes.
1073
1074    """
1075    for _, tile in enumerate(self.tiles):
1076      ax.annotate(tile.transitivity_class, xy = (tile.centre.x, tile.centre.y),
1077                  ha = "center", va = "center")
1078    return ax
1079
1080
1081  def _plot_vertex_labels(
1082      self,
1083      ax:plt.Axes,
1084      show_vertex_ids:bool = False,
1085    ) -> plt.Axes:
1086    """Plot either the Vertex transitivity class label or its sequential ID.
1087
1088    Args:
1089      ax (plt.Axes): Axes on which to plot.
1090      show_vertex_ids (bool, optional): If True plots the ID, else plots the
1091        transitivity class. Defaults to False.
1092
1093    Returns:
1094      plt.Axes: the Axes.
1095
1096    """
1097    for v in self.points.values():
1098      ax.annotate(v.ID if show_vertex_ids else v.label,
1099                  xy = (v.point.x, v.point.y), color = "k",
1100                  ha = "center", va = "center")
1101    return ax
1102
1103
1104  def _plot_edges(
1105      self,
1106      ax:plt.Axes,
1107      show_edges:bool = False,
1108      show_edge_labels:bool = False,
1109      dist:float = 0.0,
1110      offset_edges:bool = True,
1111    ) -> plt.Axes:
1112    """Plot edges, including an offset if specified and labels if requested.
1113
1114    Can also be called to only plot the labels.
1115
1116    Args:
1117      ax (plt.Axes): Axes on which to plot.
1118      show_edges (bool, optional): if True includes the edges as. a dotted blue
1119        line, optionally offset (for clarity) from the tile boundary. Defaults
1120        to False.
1121      show_edge_labels (bool, optional): if True shows an edge label. Defaults
1122        to False.
1123      dist (float, optional): a distance by which to offset the dotted line for
1124        the edge from the tile boundary. Defaults to 0.0.
1125      offset_edges (bool, optional): if True applies the edge drawing offset,
1126        if False the edge is drawn as a single line segment between its end
1127        vertices (and may not align with the sides of tiles. Defaults to True.
1128
1129    Returns:
1130      plt.Axes: the Axes.
1131
1132    """
1133    if show_edges:
1134      edges = self._get_edge_geoms(dist if offset_edges else 0)
1135      edges.plot(ax = ax, color = "dodgerblue", ls = ":")
1136    else:
1137      edges = [e.get_geometry() for e in self.edges.values()]
1138    if show_edge_labels:
1139      for ls, e in zip(edges, self.edges.values(), strict = True):
1140        c = ls.centroid
1141        ax.annotate(e.label, xy = (c.x, c.y), color = "k",
1142                    ha = "center", va = "center")
1143    return ax
1144
1145  def _plot_dual_tiles(self, ax:plt.Axes,
1146                       dist:float = 0.0) -> plt.Axes:
1147    gpd.GeoSeries(self.dual_tiles).buffer(
1148      -dist / 4, join_style = "mitre", cap_style = "square").plot(
1149        ax = ax, fc = "g", alpha = 0.25)
1150    return ax
1151
1152
1153  def plot_tiling_symmetries(
1154      self,
1155      **kwargs:float,
1156    ) -> None:
1157    """Plot the symmetries of Topology's tiling.
1158
1159    Most of the work here is delegated to `_plot_tiling_symmetry` which is run
1160    once per symmetry on a grid of plt.Axes built by this function.
1161
1162    Args:
1163      kwargs: passed through to `_plot_tiling_symmetry`
1164
1165    """
1166    n = len(self.tile_matching_transforms)
1167    nc = int(np.ceil(np.sqrt(n)))
1168    nr = int(np.ceil(n / nc))
1169    fig = plt.figure(figsize = (12, 12 * nr / nc))
1170    for i, tr in enumerate(self.tile_matching_transforms.values()):
1171      ax = fig.add_subplot(nr, nc, i + 1)
1172      self._plot_tiling_symmetry(tr, ax, **kwargs)
1173
1174
1175  def _plot_tiling_symmetry(
1176      self,
1177      transform:Transform,
1178      ax:plt.Axes,
1179      **kwargs:float,
1180    ) -> None:
1181    """Plot the supplied Transform on the supplied plt.Axes.
1182
1183    Args:
1184        transform (Transform): the Transform to plot.
1185        ax (plt.Axes): the Axes on which to plot.
1186        kwargs: passed through to plt.plot functions.
1187
1188    """
1189    tiles = gpd.GeoSeries([t.shape for t in self.tiles])
1190    base_tiles = tiles[:self.n_tiles]
1191    tiles.plot(ax = ax, fc = "k", alpha = .15, ec = "k", lw = .5)
1192    base_tiles.plot(ax = ax, fc = "#00000000", ec = "w", lw = 1, zorder = 2)
1193    transformed_base_tiles = gpd.GeoSeries(
1194      [transform.apply(g) for g in base_tiles])
1195    transformed_base_tiles.plot(ax = ax, fc = "k", alpha = .2, lw = 0, ec = "k")
1196    transform.draw(ax, **kwargs) # delegate to Transform.draw
1197    plt.axis("off")
1198
1199
1200  def transform_geometry(
1201      self,
1202      new_topology:bool,
1203      apply_to_tiles:bool,
1204      selector:str,
1205      transform_type:str,
1206      **kwargs:float) -> Topology:
1207    r"""Get a new Topology by applying specified transformation.
1208
1209    A transformation specified by `transform_type` and keyword arguments is
1210    applied to elements in the Topology whose labels match the selector
1211    parameter. The transform is optionally applied to update tiles and
1212    optionally requests a new Topology object.
1213
1214    Implemented in this way so that transformations can be applied one at a time
1215    without creating an intermediate set of new tiles, which may be invalid and
1216    fail. So, if you wish to apply (say) 3 transforms and generate a new
1217    Topology leaving the existing one intact:
1218
1219        new_topo = old_topo.transform_geometry(True,  False, "a", ...) \
1220                           .transform_geometry(False, False, "B", ...) \
1221                           .transform_geometry(False, True,  "C", ...)
1222
1223    The first transform requests a new Topology, subsequent steps do not, and it
1224    is only the last step which attempts to create the new tile polygons.
1225
1226    **kwargs supply named parameters for the requested transformation.
1227
1228    Args:
1229      new_topology (bool): if True returns a new Topology object, else returns
1230        the current Topology modified.
1231      apply_to_tiles (bool): if True attempts to create new Tiles after the
1232        transformation has been applied. Usually set to False, unless the last
1233        transformation in a pipeline, to avoid problems of topologically invalid
1234        tiles at intermediate steps.
1235      selector (str): label of elements to which to apply the transformation.
1236        Note that all letters in the supplied string are checked, so you can
1237        use e.g. "abc" to apply a transformation to edges labelled "a", "b" or
1238        "c", or "AB" for vertices labelled "A" or "B".
1239      transform_type (str): name of the type of transformation requested.
1240        Currently supported are `zigzag_edge`, `rotate_edge`, `scale_edge`,
1241        `push_vertex`, and `nudge_vertex`. Keyword arguments for each are
1242        documented in the corresponding methods.
1243      kwargs: contains any needed arguments of the requested transform_type.
1244
1245    Returns:
1246      Topology: if new_topology is True a new Topology based on this one with
1247        after transformation, if False this Topology is returned after the
1248        transformation.
1249
1250    """
1251    print("CAUTION: new Topology will probably not be correctly labelled. "
1252          "To build a correct Topology, extract the tileable attribute and "
1253          "rebuild Topology from that.")
1254    # pickle seems to be more reliable than copy.deepcopy here
1255    topo = (pickle.loads(pickle.dumps(self))   # noqa: S301
1256            if new_topology
1257            else self)
1258    transform_args = topo.get_kwargs(getattr(topo, transform_type), **kwargs)
1259    match transform_type:
1260      case "zigzag_edge":
1261        for e in topo.edges.values():
1262          if e.label in selector:
1263            topo.zigzag_edge(e, **transform_args)
1264      case "rotate_edge":
1265        for e in topo.edges.values():
1266          if e.label in selector:
1267            topo.rotate_edge(e, **transform_args)
1268      case "scale_edge":
1269        for e in topo.edges.values():
1270          if e.label in selector:
1271            topo.scale_edge(e, **transform_args)
1272      case "push_vertex":
1273        pushes = {}
1274        for v in topo.vertices_in_tiles(topo.tiles[:topo.n_tiles]):
1275          if v.label in selector:
1276            pushes[v.base_ID] = topo.push_vertex(v, **transform_args)
1277        for base_ID, (dx, dy) in pushes.items():
1278          for v in [v for v in topo.points.values() if v.base_ID == base_ID]:
1279            v.point = affine.translate(v.point, dx, dy)
1280      case "nudge_vertex":
1281         for v in topo.points.values():
1282          if v.label in selector:
1283            topo.nudge_vertex(v, **transform_args)
1284    if apply_to_tiles:
1285      for t in topo.tiles:
1286        t.set_corners_from_edges()
1287    topo.tileable.tiles.geometry = gpd.GeoSeries(
1288      [topo.tiles[i].shape for i in range(topo.n_tiles)])
1289    topo.tileable._setup_regularised_prototile()
1290    return topo
1291
1292
1293  def get_kwargs(
1294      self,
1295      fn:Callable,
1296      **kwargs:str|float,
1297    ) -> dict:
1298    """Filter the supplied kwargs to only contain arguments required by fn.
1299
1300    Args:
1301      fn (Callable): the function that is to be inspected.
1302      **kwargs (str|float): kwargs to be filtered.
1303
1304    Returns:
1305      str|float: filtered dictionary of kwargs.
1306
1307    """
1308    args = inspect.signature(fn).parameters
1309    return {k: kwargs.pop(k) for k in dict(kwargs) if k in args}
1310
1311
1312  def zigzag_edge(
1313      self,
1314      edge:Edge,
1315      start:str = "A",
1316      n:int = 2,
1317      h:float = 0.5,
1318      smoothness:int = 0,
1319    ) -> None:
1320    """Apply zigzag transformation to supplied Edge.
1321
1322    Currently this will only work correctly if h is even.
1323
1324    TODO: make it possible for odd numbers of 'peaks' to work (this may require
1325    allowing bidirectional Edges, i.e. storing Edges in both directions so that
1326    all Tile edges are drawn CW). The `start` parameter is a temporary hack for
1327    this.
1328
1329    Args:
1330      edge (Edge): Edge to transform
1331      start (str, optional): label at one end of edge which is used to determine
1332        the sense of h, enabling C-curves with an odd number n of zigs and zags
1333        to be applied. Defaults to 'A'.
1334      n (int, optional): number of zigs and zags in the edge. Defaults to 2.
1335      h (float, optional): width of the zig zags relative to edge length.
1336        Defaults to 0.5.
1337      smoothness (int, optional): spline smoothness. 0 gives a zig zag proper,
1338        higher values will produce a sinusoid. Defaults to 0.
1339
1340    """
1341    v0, v1 = edge.vertices[0], edge.vertices[1]
1342    if n % 2 == 1 and v0.label != start:
1343      h = -h
1344    ls = self.zigzag_between_points(v0.point, v1.point, n, h, smoothness)
1345    # remove current corners
1346    self.points = {k: v for k, v in self.points.items()
1347                   if k not in edge.get_corner_IDs()[1:-1]}
1348    # add the new ones
1349    new_corners = [self.add_vertex(geom.Point(xy)) for xy in ls.coords]
1350    edge.corners = edge.vertices[:1] + new_corners + edge.vertices[-1:]
1351    if edge.right_tile is not None:
1352      edge.right_tile.set_corners_from_edges(False)
1353    if edge.left_tile is not None:
1354      edge.left_tile.set_corners_from_edges(False)
1355
1356
1357  def zigzag_between_points(
1358      self,
1359      p0:geom.Point,
1360      p1:geom.Point,
1361      n:int,
1362      h:float = 1.0,
1363      smoothness:int = 0,
1364    ) -> geom.LineString:
1365    """Return a zig zag line optionally smoothed as a spline between two points.
1366
1367    Args:
1368      p0 (geom.Point): start point.
1369      p1 (geom.Point): end point.
1370      n (int): number of zig zags.
1371      h (float, optional): amplitude of zig zags relative to distance between
1372        points. Defaults to 1.0.
1373      smoothness (int, optional): number of spline smoothed points to add. If
1374        set to 0 a straight line zig zag is produced. Defaults to 0.
1375
1376    Returns:
1377      geom.LineString: the resulting zig zag line.
1378
1379    """
1380    r = p0.distance(p1)
1381    # make a sinusoidal template
1382    x = np.linspace(0, n * np.pi, n * 2 + 1, endpoint = True)
1383    y = [np.sin(x) for x in x]
1384    spline = interpolate.InterpolatedUnivariateSpline(x, y, k = 2)
1385
1386    spline_steps = (n + smoothness) * 2 + 1
1387    xs = np.linspace(0, n * np.pi, spline_steps, endpoint = True)
1388    ys = spline(xs)
1389
1390    sfx = 1 / max(x) * r
1391    sfy = h * r / 2
1392    theta = np.arctan2(p1.y - p0.y, p1.x - p0.x)
1393
1394    ls = geom.LineString(
1395      [geom.Point(x, y) for x, y in zip(xs, ys, strict = True)])
1396    ls = affine.translate(ls, 0, -(ls.bounds[1] + ls.bounds[3]) / 2)
1397    ls = affine.scale(ls, xfact = sfx, yfact = sfy, origin = (0, 0))
1398    ls = affine.rotate(ls, theta, (0, 0), use_radians = True)
1399    x0, y0 = next(iter(ls.coords))
1400    return affine.translate(ls, p0.x - x0, p0.y - y0)
1401
1402
1403  def rotate_edge(
1404      self,
1405      edge:Edge,
1406      centre:str = "",
1407      angle:float = 0,
1408    ) -> None:
1409    """Rotate edge.
1410
1411    Args:
1412      edge (Edge): the edge to rotate.
1413      centre (str): centre of rotation which should be label of one of its
1414        end point vertices or "" when rotation will be about the Edge centroid.
1415        Defaults to "".
1416      angle (float): angle of rotation.
1417
1418    """
1419    v0, v1 = edge.vertices
1420    ls = geom.LineString([v0.point, v1.point])
1421    if v0.label == centre:
1422      c = v0.point
1423    elif v1.label == centre:
1424      c = v1.point
1425    else:
1426      c = ls.centroid
1427    ls = affine.rotate(ls, angle, origin = c)
1428    v0.point, v1.point = [geom.Point(c) for c in ls.coords]
1429
1430
1431  def scale_edge(
1432      self,
1433      edge:Edge,
1434      sf:float = 1.0,
1435    ) -> None:
1436    """Scale edge.
1437
1438    Args:
1439      edge (Edge): the edge to scale.
1440      sf (float): amount by which to scale the edge.
1441
1442    """
1443    v0, v1 = edge.vertices
1444    ls = geom.LineString([v0.point, v1.point])
1445    ls = affine.scale(ls, xfact = sf, yfact = sf, origin = ls.centroid)
1446    v0.point, v1.point = [geom.Point(c) for c in ls.coords]
1447
1448
1449  def push_vertex(
1450      self,
1451      vertex:Vertex,
1452      push_d:float,
1453    ) -> tuple[float]:
1454    """Return displacement vector to push a vertex based on incident edges.
1455
1456    Args:
1457      vertex (Vertex): the vertex to push.
1458      push_d (float): the distance to push it.
1459
1460    """
1461    neighbours = [self.points[v] for v in vertex.neighbours]
1462    dists = [vertex.point.distance(v.point) for v in neighbours]
1463    x, y = vertex.point.x, vertex.point.y
1464    unit_vectors = [((x - v.point.x) / d, (y - v.point.y) / d)
1465                    for v, d in zip(neighbours, dists, strict = True)]
1466    return  (push_d * sum([xy[0] for xy in unit_vectors]),
1467             push_d * sum([xy[1] for xy in unit_vectors]))
1468
1469
1470  def nudge_vertex(
1471      self,
1472      vertex:Vertex,
1473      dx:float,
1474      dy:float,
1475    ) -> None:
1476    """Nudge vertex by specified displacement.
1477
1478    Args:
1479      vertex (Vertex): the vertext to nudge.
1480      dx (float): x displacement.
1481      dy (float): y displacement.
1482
1483    """
1484    vertex.point = affine.translate(vertex.point, dx, dy)
1485
1486
1487class Tile:
1488  """Class to represent essential features of polygons in a tiling."""
1489
1490  ID: int
1491  """integer ID number which indexes the Tile in the containing Topology tiles
1492  list."""
1493  base_ID: int
1494  """ID of corresponding Tile in the base tileable unit"""
1495  corners: list[Vertex]
1496  """list of Vertex objects. This includes all corners of the original polygon
1497  and any tiling vertices induced by (for example) a the corner of an adjacent
1498  tile lying halfway along an edge of the original polygon on which this tile
1499  is based. Vertex objects are stored in strictly clockwise sequence."""
1500  edges: list[Edge]
1501  """list of Edge objects that together compose the tile boundary."""
1502  edges_CW: list[bool]
1503  """list of Edge direction. Edges are stored only once in a Topology so some
1504  edges are in clockwise order and others  are in counter-clockwise order.
1505  These boolean flags are True if the corresponding Edge is clockwise, False if
1506  counter-clockwise."""
1507  label: str
1508  """tile_id label from the tileable source"""
1509  vertex_labels: list[str]
1510  """list of (upper case) letter labels of the tile corners (i.e. all corners,
1511  not only tiling vertices)."""
1512  edge_labels: list[str]
1513  """list of (lower case) letter labels of the tile edges (tiling edges, not
1514  tile sides)."""
1515  shape: geom.Polygon = None
1516  """the tile geometry (which may include some redundant points along sides
1517  where neighbouring tiles induce a tiling vertex). So for example a rectangle
1518  might have additional points along its sides:
1519
1520        +---+-------+
1521        |   |   2   |
1522        | 1 A---B---E---+
1523        |   |   |   4   |
1524        +---C 3 D-------+
1525            |   |
1526            +---+
1527
1528  In the above Tile 1 has additional point A, 2 has B and 3 has C and D induced
1529  by the corners of neighbouring tiles."""
1530  centre: geom.Point = None
1531  """a point centre for the Tile (determined by weavingspace.tiling_utils.
1532  incentre)."""
1533  shape_group: int = None
1534  """the tile shape group of this tile in its containing Topology."""
1535  transitivity_class: int = None
1536  """the tile transitivity class of this tile its containing Topology"""
1537
1538  def __init__(
1539      self,
1540      ID:int,
1541    ) -> None:
1542    """Class constructor.
1543
1544    Args:
1545      ID (int): sequence number ID of this vertex in the Topology.
1546
1547    """
1548    self.ID = ID
1549    self.corners = []
1550    self.edges = []
1551    self.edges_CW = []
1552    self.vertex_labels = []
1553    self.edge_labels = []
1554
1555
1556  def __str__(self) -> str:
1557    """Return string representation of the Tile.
1558
1559    Returns:
1560      str: string including Tile ID, list of corner vertex IDs and list of
1561        edge IDs.
1562
1563    """
1564    return (f"Tile {self.ID} Corners: {self.get_corner_IDs()} "
1565            f"Edges: {self.get_edge_IDs()}")
1566
1567
1568  def __repr__(self) -> str:
1569    return str(self)
1570
1571
1572  def get_corner_IDs(self) -> list[int]:
1573    """Return list of corner IDs (not Vertex objects).
1574
1575    Returns:
1576      list[int]: list of integer IDs of tile corners.
1577
1578    """
1579    return [c.ID for c in self.corners]
1580
1581
1582  def get_edge_IDs(self) -> list[tuple[int,int]]:
1583    """Return list of edge IDs (not Edge objects).
1584
1585    Returns:
1586      list[tuple[int]]: list of 2-element tuples of the start and end Vertex IDs
1587        of each edge.
1588
1589    """
1590    return [e.ID for e in self.edges]
1591
1592
1593  def set_shape_from_corners(self) -> None:
1594    """Set the shape attribute based on corners, and associated tile centre."""
1595    self.shape = geom.Polygon([c.point for c in self.corners])
1596    self.centre = tiling_utils.get_incentre(
1597      tiling_utils.get_clean_polygon(self.shape))
1598
1599
1600  def set_corners_from_edges(
1601      self,
1602      update_shape:bool = True,
1603    ) -> None:
1604    """Set corners attribute from the edges attribute.
1605
1606    Typically called after modification of topology edges. Optionally the shape
1607    attribute is NOT updated, which may save time when multiple changes to the
1608    edges of a tile are in process (i.e., only update the shape after all
1609    changes are complete).
1610
1611    Args:
1612      update_shape (bool, optional): if True the shape attribute will be
1613        updated, otherwise not. Defaults to True.
1614
1615    """
1616    self.corners = []
1617    for e, cw in zip(self.edges, self.edges_CW, strict = True):
1618      if cw: # clockwise to extend by all but the first corner
1619        self.corners.extend(e.corners[:-1])
1620      else: # counter-clockwise so extend in reverse
1621        self.corners.extend(e.corners[1:][::-1])
1622    if update_shape:
1623      self.set_shape_from_corners()
1624
1625
1626  def set_edge_directions(self) -> None:
1627    """Set up edges_CW attribute by inspection of the edges list.
1628
1629    It is (frankly!) hard to keep track of the correct sequence of CW/CCW order
1630    of edges as new ones are created or old ones merged. This method inspects
1631    the 'tail-head' relations between consecutive edges to set these flags
1632    correctly.
1633
1634    The test is simply to check if the 'tail' Vertex ID in each edge appears
1635    in the ID tuple of the following edge, i.e. if successive edge
1636    IDs are (0, 1) (2, 1) or (0, 1) (1, 2), then edge (0, 1) is in clockwise
1637    direction, but if we have (0, 1) (2, 3) then it is not.
1638    """
1639    edge_IDs = self.get_edge_IDs()
1640    self.edges_CW = [e1[-1] in e2 for e1, e2 in
1641                     zip(edge_IDs, edge_IDs[1:] + edge_IDs[:1], strict = True)]
1642
1643
1644  def insert_vertex_at(
1645      self,
1646      v:Vertex,
1647      i:int,
1648      update_shape:bool = False,
1649    ) -> tuple[tuple[int,int],tuple[tuple[int,int],...]]:
1650    """Insert the Vertex into tile at index position i.
1651
1652    Both corners and edges attributes are updated, and the old edge IDs for
1653    removal and the new edge itself are returned to the calling context (the
1654    containing Topology) for update of its edges collection. Optionally update
1655    the shape attribute.
1656
1657    This is NOT a generic vertex insertion method: it is only for use during
1658    Topology initialisation, and does not guarantee correct maintenance of
1659    all tile, edge and vertex relations in the general case---at any rate it
1660    has not been tested for this!
1661
1662    Args:
1663      v (Vertex): the Vertex to insert.
1664      i (int): index position in current corners after which to insert
1665        supplied Vertex.
1666      update_shape (bool, optional): if True shape attribute is updated.
1667        Defaults to False.
1668
1669    Returns:
1670      tuple: the (tuple) ID of the old edge which should be deleted, and
1671        the new Edges arising from insertion of this Vertex.
1672
1673    """
1674    self.corners = self.corners[:i] + [v] + self.corners[i:]
1675    old_edge = self.edges[i - 1]
1676    # store current ID of the affected edge for return to calling context
1677    old_edge_ID = old_edge.ID
1678    new_edges = old_edge.insert_vertex(v, self.corners[i - 1])
1679    self.edges = self.edges[:(i-1)] + new_edges + self.edges[i:]
1680    self.set_edge_directions()
1681    if update_shape:
1682      self.set_shape_from_corners()
1683    return old_edge_ID, new_edges
1684
1685
1686  def merge_edges_at_vertex(
1687      self,
1688      v:Vertex,
1689    ) -> tuple:
1690    """Merge edges that meet at the supplied Vertex.
1691
1692    It is assumed that only two tiles are impacted this one, and its neighbour
1693    across the Edge on which v lies. Both are updated. For this reason the work
1694    is delegated to `get_updated_edges_from_merge` which is run on both affected
1695    tiles, but only determines the edges to remove and the new edge to be added
1696    once. See that method for details.
1697
1698    Args:
1699      v (Vertex): Vertex at which to merge Edges. This should currently be an
1700        end
1701
1702    Returns:
1703      tuple: 2 item list of the edge IDs to be removed and a new Edge object to
1704        be added by the calling context (i.e. the containing Topology).
1705
1706    """
1707    to_remove, new_edge = self.get_updated_edges_from_merge(v)
1708    if len(v.tiles) > 1:
1709      v.tiles[1].get_updated_edges_from_merge(v, new_edge)
1710    return to_remove, new_edge
1711
1712
1713  def get_updated_edges_from_merge(
1714      self,
1715      v:Vertex,
1716      new_edge:Edge = None,
1717    ) -> None|tuple[tuple[tuple[int,int], tuple[int,int]], Edge]:
1718    """Update edges and edges_CW attributes based on insertion of Vertex.
1719
1720    If new_edge is supplied then the neighbour tile at v has already created
1721    the needed new Edge and this Edge is the one that will be 'slotted in' at
1722    the appropriate spot in the edges list.
1723
1724    The edges_CW is also updated to maintain correct directions of the edges.
1725    The corners attribute is unaffected by these changes.
1726
1727    Args:
1728      v (Vertex): existing Vertex at which to carry out the merge.
1729      new_edge (Edge, optional): if another Tile has already carried out this
1730        merge this should be the resulting new Edge for insertion into this
1731        Tile. Defaults to None (when the new Edge will be constructed).
1732
1733    Returns:
1734      None|tuple: either None (if a new edge was supplied) or a tuple
1735        of the two edge IDs to be removed and the new edge added for return to
1736        the calling context (i.e. the containing Topology).
1737
1738    """
1739    # get the two edge list index positions in which Vertex v is found
1740    i, j = self.get_edge_IDs_including_vertex(v)
1741    if new_edge is None: # then we must make a new one
1742      # also record existing edge IDs to be removed
1743      to_remove = [self.edges[i].ID, self.edges[j].ID]
1744      new_edge = self.get_merged_edge(i, j)
1745      return_edge_updates = True
1746    else:
1747      return_edge_updates = False
1748    if abs(i - j) != 1:
1749      # edge indices 'wrap' around from end of edge list to start so drop
1750      # first and last current edges and stick new one on at the end
1751      self.edges = self.edges[1:-1] + [new_edge]
1752    else:
1753      # insert new edge into list in place of the two old ones
1754      self.edges = self.edges[:i] + [new_edge] + self.edges[j+1:]
1755    # update the edge directions
1756    self.set_edge_directions()
1757    if return_edge_updates:
1758      return to_remove, new_edge
1759    return None
1760
1761
1762  def get_edge_IDs_including_vertex(
1763      self,
1764      v:Vertex,
1765    ) -> tuple[int]:
1766    """Get the (two) index positions of the edges that include supplied Vertex.
1767
1768    Args:
1769        v (Vertex): Vertex of interest.
1770
1771    Returns:
1772      tuple[int]: two index positions of Edges in edges list that contain v.
1773
1774    """
1775    return (i for i, e in enumerate(self.edges) if v.ID in e.ID)
1776
1777
1778  def get_merged_edge(self, i:int, j:int) -> Edge:
1779    """Return edge made by merging existing edges at i and j in the edges list.
1780
1781    For example, if the current list of edge IDs was
1782
1783        (0 1 2) (4 2) (4 5) (5 0)
1784
1785    and the merge requested is 0 and 1, the resulting new edge is constructed
1786    from vertices (0 1 2 4).
1787
1788    Returns:
1789      Edge: the requested new Edge.
1790
1791    """
1792    # if i and j are not consecutive, then j is predecessor edge
1793    if abs(i - j) != 1:
1794      i, j = j, i
1795    # get edges and their directions
1796    ei, ej = self.edges[i], self.edges[j]
1797    CWi, CWj = self.edges_CW[i], self.edges_CW[j]
1798    # DON'T MESS WITH THIS!!!
1799    # for predecessors (the head) we want everything including the Vertex
1800    # where the merge is occurring; for successors (the tail) we want all but
1801    # the first Vertex (which is the one where the merge is occurring). In both
1802    # cases contingent on whether existing Edges are CW or CCW we may need to
1803    # flip the Vertex sequence to ensure that the merge Vertex is in the middle
1804    # of the new edge that will be created
1805    head = ei.corners if CWi else ei.corners[::-1]
1806    tail = ej.corners[1:] if CWj else ej.corners[::-1][1:]
1807    v_sequence = (head if CWi else head[::-1]) + (tail if CWj else tail[::-1])
1808    return Edge(v_sequence)
1809
1810
1811  def offset_corners(
1812      self,
1813      offset:int,
1814    ) -> None:
1815    """Shift shape, corners, edges, and edges_CW by an offset amount.
1816
1817    This is used to align tiles that are similar, which is required for correct
1818    transfer of 'base' tile labelling on to 'radius 1' tiles during Topology
1819    construction.
1820
1821    Args:
1822      offset (int): the number of positions to shift the lists.
1823
1824    """
1825    if offset is not None or offset != 0:
1826      self.corners = self.corners[offset:] + self.corners[:offset]
1827      self.shape = geom.Polygon([c.point for c in self.corners])
1828      self.edges = self.edges[offset:] + self.edges[:offset]
1829      self.edges_CW = self.edges_CW[offset:] + self.edges_CW[:offset]
1830
1831
1832  def get_edge_label(self, e:Edge) -> str:
1833    """Return edge label of specified edge.
1834
1835    Args:
1836        e (Edge): Edge whose label is required.
1837
1838    Returns:
1839        str: requested edge label.
1840
1841    """
1842    return self.edge_labels[self.get_edge_IDs().index(e.ID)]
1843
1844
1845  def get_corner_label(self, v:Vertex) -> str:
1846    """Return corner label of specified corner.
1847
1848    Args:
1849        v (Vertex): corner whose label is required.
1850
1851    Returns:
1852        str: requested corner label.
1853
1854    """
1855    return self.edge_labels[self.get_corner_IDs().index(v.ID)]
1856
1857
1858  def angle_at(self, v:Vertex) -> float:
1859    """Return interior angle at the specified corner (in degrees).
1860
1861    Args:
1862        v (Vertex): corner where angle is requested.
1863
1864    Returns:
1865        float: angle at corner v in degrees.
1866
1867    """
1868    i = self.corners.index(v)
1869    n = len(self.corners)
1870    return tiling_utils.get_inner_angle(self.corners[i-1].point,
1871                                        self.corners[i].point,
1872                                        self.corners[(i + 1) % n].point)
1873
1874
1875class Vertex:
1876  """Class to store attributes of a vertex in a tiling."""
1877
1878  point: geom.Point
1879  """point (geom.Point): point location of the vertex."""
1880  ID: int
1881  """integer (mostly but not necessarily in sequence) of vertex keyed into the
1882  points dictionary of the containing Topology."""
1883  tiles: list[Tile]
1884  """list of Tiles incident on this vertex."""
1885  neighbours: list[int]
1886  """list of the immediately adjacent other corner IDs. Only required to
1887  determine if a point is a tiling vertex (when it will have) three or more
1888  neighbours, so only IDs are stored."""
1889  base_ID: int = 1_000_000
1890  """ID of corresponding Vertex in the tileable base_unit"""
1891  transitivity_class: int = None
1892  """transitivity class of the vertex under symmetries of the tiling"""
1893  label: str = ""
1894  """the (upper case letter) label of the vertex under the symmetries of the
1895  tiling."""
1896  is_tiling_vertex: bool = True
1897  """is_tiling_vertex (bool): True if this is a tiling vertex, rather than a
1898  tile corner. E.g., A below is a corner, not a tiling vertex. B is a tiling
1899  vertex:
1900
1901      +-------+
1902      | 1     |
1903      |   A---B---+
1904      |   | 2     |
1905      +---C   +---+
1906          |   |
1907          +---+"""
1908
1909
1910  def __init__(
1911      self,
1912      point:geom.Point,
1913      ID:int,
1914    ) -> None:
1915    """Class constructor.
1916
1917    Args:
1918      point (geom.Point): point location of the vertex.
1919      ID (int): a unique integer ID (which will be its key in the containing
1920        Topology points dictionary).
1921
1922    """
1923    self.point = point
1924    self.ID = ID
1925    self.base_ID = self.ID
1926    self.tiles = []
1927    self.neighbours = []
1928
1929
1930  def __str__(self) -> str:
1931    """Return string representation of Vertex.
1932
1933    Returns:
1934        str: string including ID, point and list of incident Tiles.
1935
1936    """
1937    return f"Vertex {self.ID} at {self.point} Tiles: {self.get_tile_IDs()}"
1938
1939
1940  def __repr__(self) -> str:
1941    return str(self)
1942
1943
1944  def get_tile_IDs(self) -> list[int]:
1945    """Return list of Tile IDs (not the Tiles themselves).
1946
1947    Returns:
1948        list[int]: list of Tile IDs
1949
1950    """
1951    return [t.ID for t in self.tiles]
1952
1953
1954  def add_tile(self, tile:Tile) -> None:
1955    """Add supplied Tile to the tiles list if it is not already present.
1956
1957    Args:
1958        tile (Tile): Tile to add.
1959
1960    """
1961    if tile not in self.tiles:
1962      self.tiles.append(tile)
1963
1964
1965  def add_neighbour(
1966      self,
1967      vertex_id:int,
1968    ) -> None:
1969    """Add supplied ID to the neighbours list if it is not already present.
1970
1971    Args:
1972      vertex_id (int): ID to add to the neighbours list.
1973
1974    """
1975    if vertex_id not in self.neighbours:
1976      self.neighbours.append(vertex_id)
1977
1978
1979  def clockwise_order_incident_tiles(self) -> None:
1980    """Reorder tiles list clockwise (this is for dual tiling construction)."""
1981    cw_order = self._order_of_pts_cw_around_centre(
1982      [t.centre for t in self.tiles], self.point)
1983    self.tiles = [self.tiles[i] for i in cw_order]
1984
1985
1986  def is_interior(self) -> bool:
1987    """Test if vertex is completely enclosed by its incident Tiles.
1988
1989    Based on summing the interior angles of the incident tiles at this vertex.
1990
1991    Returns:
1992        bool: True if vertex is completely enclosed by incident Tiles.
1993
1994    """
1995    return abs(360 - sum([t.angle_at(self) for t in self.tiles])) \
1996                     < tiling_utils.RESOLUTION
1997
1998
1999  def _order_of_pts_cw_around_centre(
2000      self,
2001      pts:list[geom.Point],
2002      centre:geom.Point,
2003    ) -> list[int]:
2004    """Return order of points clockwise relative to centre point.
2005
2006    Args:
2007      pts (list[geom.Point]): list of points to order.
2008      centre (geom.Point): centre relative to which CW order is determined.
2009
2010    Returns:
2011      list[int]: list of indices of reordered points.
2012
2013    """
2014    dx = [p.x - centre.x for p in pts]
2015    dy = [p.y - centre.y for p in pts]
2016    angles = [np.arctan2(dy, dx) for dx, dy in zip(dx, dy, strict = True)]
2017    d = dict(zip(angles, range(len(pts)), strict = True))
2018    return [i for angle, i in sorted(d.items(), reverse=True)]
2019
2020
2021class Edge:
2022  """Class to represent edges in a tiling (not tile sides)."""
2023
2024  ID: tuple[int]
2025  """IDs of the vertices at ends of the edge. Used as key in the containing
2026  Topology's edges dictionary."""
2027  vertices: list[Vertex]
2028  """two item list of the end vertices."""
2029  corners: list[Vertex]
2030  """list of all the vertices in the edge (including its end vertices). In a
2031  'normal' edge to edge tiling corners and vertices will be identical."""
2032  right_tile: Tile = None
2033  """the tile to the right of the edge traversed from its first to its last
2034  vertex. Given clockwise winding default, all edges will have a right_tile."""
2035  left_tile: Tile = None
2036  """the tile to the left of the edge traversed from its first to its last
2037  vertex. Exterior edges of the tiles in a Topology will not have a left_tile.
2038  """
2039  base_ID: tuple[int] = (1_000_000, 1_000_000)
2040  """ID of corresponding edge in the base tileable"""
2041  transitivity_class: int = None
2042  """transitivity class of the edge under symmetries of the tiling"""
2043  label: str = ""
2044  """the (lower case letter) label of the edge under the symmetries of the
2045  tiling."""
2046
2047  def __init__(
2048      self,
2049      corners:list[Vertex],
2050    ) -> None:
2051    """Class constructor.
2052
2053    Initialises the corners and vertices lists and sets ID to (vertices[0].ID,
2054    vertices[1].ID). The vertices list is all the corners with is_tiling_vertex
2055    property True -- Note that during initialisation the default of this
2056    property is True until after the relations between tiles and vertices have
2057    been determined.
2058
2059    Args:
2060      corners (list[Vertex]): list of all corners along the edge.
2061
2062    """
2063    self.corners = corners
2064    self.vertices = [v for v in self.corners if v.is_tiling_vertex]
2065    self.ID = tuple(v.ID for v in self.vertices)
2066
2067
2068  def __str__(self) -> str:
2069    """Return a string representation of the Edge.
2070
2071    Returns:
2072      str: include ID and a list of corner vertex IDs.
2073
2074    """
2075    return f"Edge {self.ID} Corners: {[c.ID for c in self.corners]}"
2076
2077
2078  def __repr__(self) -> str:
2079    return str(self)
2080
2081
2082  def get_corner_IDs(self) -> list[int]:
2083    """Return the IDs of edge corners.
2084
2085    Returns:
2086      list[int]: IDs of all corners.
2087
2088    """
2089    return [c.ID for c in self.corners]
2090
2091
2092  def get_vertex_IDs(self) -> list[int]:
2093    """Return IDs of edge vertices.
2094
2095    Returns:
2096      list[int]: list of IDs of the vertices.
2097
2098    """
2099    return [v.ID for v in self.vertices]
2100
2101
2102  def insert_vertex(self, v:Vertex, predecessor:Vertex) -> list[Edge]:
2103    """Insert vertex after predecessor and return modified edge and new edge.
2104
2105    If the initial edge was (say) (0 1 2 5) and the predecessor was set to 1
2106    the returned edges would be (0 1 v) and (v 2 5).
2107    """
2108    i = self.corners.index(predecessor)
2109    new_edge = Edge([v] + self.corners[(i+1):])
2110    if self.right_tile is not None:
2111      new_edge.right_tile = self.right_tile
2112    if self.left_tile is not None:
2113      new_edge.left_tile = self.left_tile
2114    self.corners = self.corners[:(i+1)] + [v]
2115    self.vertices = [self.vertices[0], v]
2116    self.ID = tuple(v.ID for v in self.vertices)
2117    return [self, new_edge]
2118
2119
2120  def get_geometry(
2121      self,
2122      forward:bool = True,
2123    ) -> geom.LineString:
2124    """Return geom.LineString representing geometry (including corners).
2125
2126    Args:
2127      forward (bool, optional): if True the returned LineString starts at
2128        corners[0], else at corners[-1]. Defaults to True.
2129
2130    Returns:
2131      geom.LineString: the required LineString.
2132
2133    """
2134    if forward:
2135      return geom.LineString([v.point for v in self.corners])
2136    return geom.LineString([v.point for v in self.corners[::-1]])
2137
2138
2139  def get_topology(
2140      self,
2141      forward:bool = True,
2142    ) -> geom.LineString:
2143    """Return LineString connecting vertices of this edge.
2144
2145    Args:
2146      forward (bool, optional): if True LineString starts at vertices[0],
2147        else at vertices[1]. Defaults to True.
2148
2149    Returns:
2150        geom.LineString: the required LineString.
2151
2152    """
2153    if forward:
2154      return geom.LineString([v.point for v in self.vertices])
2155    return geom.LineString([v.point for v in self.vertices[::-1]])
LABELS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'AB', 'AC', 'AD', 'AE', 'AF', 'AG', 'AH', 'AI', 'AJ', 'AK', 'AL', 'AM', 'AN', 'AO', 'AP', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AV', 'AW', 'AX', 'AY', 'AZ', 'AA', 'AB', 'AC', 'AD', 'AE', 'AF', 'AG', 'AH', 'AI', 'AJ', 'AK', 'AL', 'AM', 'AN', 'AO', 'AP', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AV', 'AW', 'AX', 'AY', 'AZ', 'BC', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BK', 'BL', 'BM', 'BN', 'BO', 'BP', 'BQ', 'BR', 'BS', 'BT', 'BU', 'BV', 'BW', 'BX', 'BY', 'BZ', 'BA', 'BB', 'BC', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BK', 'BL', 'BM', 'BN', 'BO', 'BP', 'BQ', 'BR', 'BS', 'BT', 'BU', 'BV', 'BW', 'BX', 'BY', 'BZ', 'CD', 'CE', 'CF', 'CG', 'CH', 'CI', 'CJ', 'CK', 'CL', 'CM', 'CN', 'CO', 'CP', 'CQ', 'CR', 'CS', 'CT', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'CA', 'CB', 'CC', 'CD', 'CE', 'CF', 'CG', 'CH', 'CI', 'CJ', 'CK', 'CL', 'CM', 'CN', 'CO', 'CP', 'CQ', 'CR', 'CS', 'CT', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DE', 'DF', 'DG', 'DH', 'DI', 'DJ', 'DK', 'DL', 'DM', 'DN', 'DO', 'DP', 'DQ', 'DR', 'DS', 'DT', 'DU', 'DV', 'DW', 'DX', 'DY', 'DZ', 'DA', 'DB', 'DC', 'DD', 'DE', 'DF', 'DG', 'DH', 'DI', 'DJ', 'DK', 'DL', 'DM', 'DN', 'DO', 'DP', 'DQ', 'DR', 'DS', 'DT', 'DU', 'DV', 'DW', 'DX', 'DY', 'DZ', 'EF', 'EG', 'EH', 'EI', 'EJ', 'EK', 'EL', 'EM', 'EN', 'EO', 'EP', 'EQ', 'ER', 'ES', 'ET', 'EU', 'EV', 'EW', 'EX', 'EY', 'EZ', 'EA', 'EB', 'EC', 'ED', 'EE', 'EF', 'EG', 'EH', 'EI', 'EJ', 'EK', 'EL', 'EM', 'EN', 'EO', 'EP', 'EQ', 'ER', 'ES', 'ET', 'EU', 'EV', 'EW', 'EX', 'EY', 'EZ', 'FG', 'FH', 'FI', 'FJ', 'FK', 'FL', 'FM', 'FN', 'FO', 'FP', 'FQ', 'FR', 'FS', 'FT', 'FU', 'FV', 'FW', 'FX', 'FY', 'FZ', 'FA', 'FB', 'FC', 'FD', 'FE', 'FF', 'FG', 'FH', 'FI', 'FJ', 'FK', 'FL', 'FM', 'FN', 'FO', 'FP', 'FQ', 'FR', 'FS', 'FT', 'FU', 'FV', 'FW', 'FX', 'FY', 'FZ', 'GH', 'GI', 'GJ', 'GK', 'GL', 'GM', 'GN', 'GO', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GV', 'GW', 'GX', 'GY', 'GZ', 'GA', 'GB', 'GC', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GJ', 'GK', 'GL', 'GM', 'GN', 'GO', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GV', 'GW', 'GX', 'GY', 'GZ', 'HI', 'HJ', 'HK', 'HL', 'HM', 'HN', 'HO', 'HP', 'HQ', 'HR', 'HS', 'HT', 'HU', 'HV', 'HW', 'HX', 'HY', 'HZ', 'HA', 'HB', 'HC', 'HD', 'HE', 'HF', 'HG', 'HH', 'HI', 'HJ', 'HK', 'HL', 'HM', 'HN', 'HO', 'HP', 'HQ', 'HR', 'HS', 'HT', 'HU', 'HV', 'HW', 'HX', 'HY', 'HZ', 'IJ', 'IK', 'IL', 'IM', 'IN', 'IO', 'IP', 'IQ', 'IR', 'IS', 'IT', 'IU', 'IV', 'IW', 'IX', 'IY', 'IZ', 'IA', 'IB', 'IC', 'ID', 'IE', 'IF', 'IG', 'IH', 'II', 'IJ', 'IK', 'IL', 'IM', 'IN', 'IO', 'IP', 'IQ', 'IR', 'IS', 'IT', 'IU', 'IV', 'IW', 'IX', 'IY', 'IZ', 'JK', 'JL', 'JM', 'JN', 'JO', 'JP', 'JQ', 'JR', 'JS', 'JT', 'JU', 'JV', 'JW', 'JX', 'JY', 'JZ', 'JA', 'JB', 'JC', 'JD', 'JE', 'JF', 'JG', 'JH', 'JI', 'JJ', 'JK', 'JL', 'JM', 'JN', 'JO', 'JP', 'JQ', 'JR', 'JS', 'JT', 'JU', 'JV', 'JW', 'JX', 'JY', 'JZ', 'KL', 'KM', 'KN', 'KO', 'KP', 'KQ', 'KR', 'KS', 'KT', 'KU', 'KV', 'KW', 'KX', 'KY', 'KZ', 'KA', 'KB', 'KC', 'KD', 'KE', 'KF', 'KG', 'KH', 'KI', 'KJ', 'KK', 'KL', 'KM', 'KN', 'KO', 'KP', 'KQ', 'KR', 'KS', 'KT', 'KU', 'KV', 'KW', 'KX', 'KY', 'KZ', 'LM', 'LN', 'LO', 'LP', 'LQ', 'LR', 'LS', 'LT', 'LU', 'LV', 'LW', 'LX', 'LY', 'LZ', 'LA', 'LB', 'LC', 'LD', 'LE', 'LF', 'LG', 'LH', 'LI', 'LJ', 'LK', 'LL', 'LM', 'LN', 'LO', 'LP', 'LQ', 'LR', 'LS', 'LT', 'LU', 'LV', 'LW', 'LX', 'LY', 'LZ', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'MA', 'MB', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MI', 'MJ', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NO', 'NP', 'NQ', 'NR', 'NS', 'NT', 'NU', 'NV', 'NW', 'NX', 'NY', 'NZ', 'NA', 'NB', 'NC', 'ND', 'NE', 'NF', 'NG', 'NH', 'NI', 'NJ', 'NK', 'NL', 'NM', 'NN', 'NO', 'NP', 'NQ', 'NR', 'NS', 'NT', 'NU', 'NV', 'NW', 'NX', 'NY', 'NZ', 'OP', 'OQ', 'OR', 'OS', 'OT', 'OU', 'OV', 'OW', 'OX', 'OY', 'OZ', 'OA', 'OB', 'OC', 'OD', 'OE', 'OF', 'OG', 'OH', 'OI', 'OJ', 'OK', 'OL', 'OM', 'ON', 'OO', 'OP', 'OQ', 'OR', 'OS', 'OT', 'OU', 'OV', 'OW', 'OX', 'OY', 'OZ', 'PQ', 'PR', 'PS', 'PT', 'PU', 'PV', 'PW', 'PX', 'PY', 'PZ', 'PA', 'PB', 'PC', 'PD', 'PE', 'PF', 'PG', 'PH', 'PI', 'PJ', 'PK', 'PL', 'PM', 'PN', 'PO', 'PP', 'PQ', 'PR', 'PS', 'PT', 'PU', 'PV', 'PW', 'PX', 'PY', 'PZ', 'QR', 'QS', 'QT', 'QU', 'QV', 'QW', 'QX', 'QY', 'QZ', 'QA', 'QB', 'QC', 'QD', 'QE', 'QF', 'QG', 'QH', 'QI', 'QJ', 'QK', 'QL', 'QM', 'QN', 'QO', 'QP', 'QQ', 'QR', 'QS', 'QT', 'QU', 'QV', 'QW', 'QX', 'QY', 'QZ', 'RS', 'RT', 'RU', 'RV', 'RW', 'RX', 'RY', 'RZ', 'RA', 'RB', 'RC', 'RD', 'RE', 'RF', 'RG', 'RH', 'RI', 'RJ', 'RK', 'RL', 'RM', 'RN', 'RO', 'RP', 'RQ', 'RR', 'RS', 'RT', 'RU', 'RV', 'RW', 'RX', 'RY', 'RZ', 'ST', 'SU', 'SV', 'SW', 'SX', 'SY', 'SZ', 'SA', 'SB', 'SC', 'SD', 'SE', 'SF', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SP', 'SQ', 'SR', 'SS', 'ST', 'SU', 'SV', 'SW', 'SX', 'SY', 'SZ', 'TU', 'TV', 'TW', 'TX', 'TY', 'TZ', 'TA', 'TB', 'TC', 'TD', 'TE', 'TF', 'TG', 'TH', 'TI', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TP', 'TQ', 'TR', 'TS', 'TT', 'TU', 'TV', 'TW', 'TX', 'TY', 'TZ', 'UV', 'UW', 'UX', 'UY', 'UZ', 'UA', 'UB', 'UC', 'UD', 'UE', 'UF', 'UG', 'UH', 'UI', 'UJ', 'UK', 'UL', 'UM', 'UN', 'UO', 'UP', 'UQ', 'UR', 'US', 'UT', 'UU', 'UV', 'UW', 'UX', 'UY', 'UZ', 'VW', 'VX', 'VY', 'VZ', 'VA', 'VB', 'VC', 'VD', 'VE', 'VF', 'VG', 'VH', 'VI', 'VJ', 'VK', 'VL', 'VM', 'VN', 'VO', 'VP', 'VQ', 'VR', 'VS', 'VT', 'VU', 'VV', 'VW', 'VX', 'VY', 'VZ', 'WX', 'WY', 'WZ', 'WA', 'WB', 'WC', 'WD', 'WE', 'WF', 'WG', 'WH', 'WI', 'WJ', 'WK', 'WL', 'WM', 'WN', 'WO', 'WP', 'WQ', 'WR', 'WS', 'WT', 'WU', 'WV', 'WW', 'WX', 'WY', 'WZ', 'XY', 'XZ', 'XA', 'XB', 'XC', 'XD', 'XE', 'XF', 'XG', 'XH', 'XI', 'XJ', 'XK', 'XL', 'XM', 'XN', 'XO', 'XP', 'XQ', 'XR', 'XS', 'XT', 'XU', 'XV', 'XW', 'XX', 'XY', 'XZ', 'YZ', 'YA', 'YB', 'YC', 'YD', 'YE', 'YF', 'YG', 'YH', 'YI', 'YJ', 'YK', 'YL', 'YM', 'YN', 'YO', 'YP', 'YQ', 'YR', 'YS', 'YT', 'YU', 'YV', 'YW', 'YX', 'YY', 'YZ', 'ZA', 'ZB', 'ZC', 'ZD', 'ZE', 'ZF', 'ZG', 'ZH', 'ZI', 'ZJ', 'ZK', 'ZL', 'ZM', 'ZN', 'ZO', 'ZP', 'ZQ', 'ZR', 'ZS', 'ZT', 'ZU', 'ZV', 'ZW', 'ZX', 'ZY', 'ZZ', 'AB', 'AC', 'AD', 'AE', 'AF', 'AG', 'AH', 'AI', 'AJ', 'AK', 'AL', 'AM', 'AN', 'AO', 'AP', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AV', 'AW', 'AX', 'AY', 'AZ', 'BC', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BK', 'BL', 'BM', 'BN', 'BO', 'BP', 'BQ', 'BR', 'BS', 'BT', 'BU', 'BV', 'BW', 'BX', 'BY', 'BZ', 'CD', 'CE', 'CF', 'CG', 'CH', 'CI', 'CJ', 'CK', 'CL', 'CM', 'CN', 'CO', 'CP', 'CQ', 'CR', 'CS', 'CT', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DE', 'DF', 'DG', 'DH', 'DI', 'DJ', 'DK', 'DL', 'DM', 'DN', 'DO', 'DP', 'DQ', 'DR', 'DS', 'DT', 'DU', 'DV', 'DW', 'DX', 'DY', 'DZ', 'EF', 'EG', 'EH', 'EI', 'EJ', 'EK', 'EL', 'EM', 'EN', 'EO', 'EP', 'EQ', 'ER', 'ES', 'ET', 'EU', 'EV', 'EW', 'EX', 'EY', 'EZ', 'FG', 'FH', 'FI', 'FJ', 'FK', 'FL', 'FM', 'FN', 'FO', 'FP', 'FQ', 'FR', 'FS', 'FT', 'FU', 'FV', 'FW', 'FX', 'FY', 'FZ', 'GH', 'GI', 'GJ', 'GK', 'GL', 'GM', 'GN', 'GO', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GV', 'GW', 'GX', 'GY', 'GZ', 'HI', 'HJ', 'HK', 'HL', 'HM', 'HN', 'HO', 'HP', 'HQ', 'HR', 'HS', 'HT', 'HU', 'HV', 'HW', 'HX', 'HY', 'HZ', 'IJ', 'IK', 'IL', 'IM', 'IN', 'IO', 'IP', 'IQ', 'IR', 'IS', 'IT', 'IU', 'IV', 'IW', 'IX', 'IY', 'IZ', 'JK', 'JL', 'JM', 'JN', 'JO', 'JP', 'JQ', 'JR', 'JS', 'JT', 'JU', 'JV', 'JW', 'JX', 'JY', 'JZ', 'KL', 'KM', 'KN', 'KO', 'KP', 'KQ', 'KR', 'KS', 'KT', 'KU', 'KV', 'KW', 'KX', 'KY', 'KZ', 'LM', 'LN', 'LO', 'LP', 'LQ', 'LR', 'LS', 'LT', 'LU', 'LV', 'LW', 'LX', 'LY', 'LZ', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NO', 'NP', 'NQ', 'NR', 'NS', 'NT', 'NU', 'NV', 'NW', 'NX', 'NY', 'NZ', 'OP', 'OQ', 'OR', 'OS', 'OT', 'OU', 'OV', 'OW', 'OX', 'OY', 'OZ', 'PQ', 'PR', 'PS', 'PT', 'PU', 'PV', 'PW', 'PX', 'PY', 'PZ', 'QR', 'QS', 'QT', 'QU', 'QV', 'QW', 'QX', 'QY', 'QZ', 'RS', 'RT', 'RU', 'RV', 'RW', 'RX', 'RY', 'RZ', 'ST', 'SU', 'SV', 'SW', 'SX', 'SY', 'SZ', 'TU', 'TV', 'TW', 'TX', 'TY', 'TZ', 'UV', 'UW', 'UX', 'UY', 'UZ', 'VW', 'VX', 'VY', 'VZ', 'WX', 'WY', 'WZ', 'XY', 'XZ', 'YZ']
labels = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'ab', 'ac', 'ad', 'ae', 'af', 'ag', 'ah', 'ai', 'aj', 'ak', 'al', 'am', 'an', 'ao', 'ap', 'aq', 'ar', 'as', 'at', 'au', 'av', 'aw', 'ax', 'ay', 'az', 'aa', 'ab', 'ac', 'ad', 'ae', 'af', 'ag', 'ah', 'ai', 'aj', 'ak', 'al', 'am', 'an', 'ao', 'ap', 'aq', 'ar', 'as', 'at', 'au', 'av', 'aw', 'ax', 'ay', 'az', 'bc', 'bd', 'be', 'bf', 'bg', 'bh', 'bi', 'bj', 'bk', 'bl', 'bm', 'bn', 'bo', 'bp', 'bq', 'br', 'bs', 'bt', 'bu', 'bv', 'bw', 'bx', 'by', 'bz', 'ba', 'bb', 'bc', 'bd', 'be', 'bf', 'bg', 'bh', 'bi', 'bj', 'bk', 'bl', 'bm', 'bn', 'bo', 'bp', 'bq', 'br', 'bs', 'bt', 'bu', 'bv', 'bw', 'bx', 'by', 'bz', 'cd', 'ce', 'cf', 'cg', 'ch', 'ci', 'cj', 'ck', 'cl', 'cm', 'cn', 'co', 'cp', 'cq', 'cr', 'cs', 'ct', 'cu', 'cv', 'cw', 'cx', 'cy', 'cz', 'ca', 'cb', 'cc', 'cd', 'ce', 'cf', 'cg', 'ch', 'ci', 'cj', 'ck', 'cl', 'cm', 'cn', 'co', 'cp', 'cq', 'cr', 'cs', 'ct', 'cu', 'cv', 'cw', 'cx', 'cy', 'cz', 'de', 'df', 'dg', 'dh', 'di', 'dj', 'dk', 'dl', 'dm', 'dn', 'do', 'dp', 'dq', 'dr', 'ds', 'dt', 'du', 'dv', 'dw', 'dx', 'dy', 'dz', 'da', 'db', 'dc', 'dd', 'de', 'df', 'dg', 'dh', 'di', 'dj', 'dk', 'dl', 'dm', 'dn', 'do', 'dp', 'dq', 'dr', 'ds', 'dt', 'du', 'dv', 'dw', 'dx', 'dy', 'dz', 'ef', 'eg', 'eh', 'ei', 'ej', 'ek', 'el', 'em', 'en', 'eo', 'ep', 'eq', 'er', 'es', 'et', 'eu', 'ev', 'ew', 'ex', 'ey', 'ez', 'ea', 'eb', 'ec', 'ed', 'ee', 'ef', 'eg', 'eh', 'ei', 'ej', 'ek', 'el', 'em', 'en', 'eo', 'ep', 'eq', 'er', 'es', 'et', 'eu', 'ev', 'ew', 'ex', 'ey', 'ez', 'fg', 'fh', 'fi', 'fj', 'fk', 'fl', 'fm', 'fn', 'fo', 'fp', 'fq', 'fr', 'fs', 'ft', 'fu', 'fv', 'fw', 'fx', 'fy', 'fz', 'fa', 'fb', 'fc', 'fd', 'fe', 'ff', 'fg', 'fh', 'fi', 'fj', 'fk', 'fl', 'fm', 'fn', 'fo', 'fp', 'fq', 'fr', 'fs', 'ft', 'fu', 'fv', 'fw', 'fx', 'fy', 'fz', 'gh', 'gi', 'gj', 'gk', 'gl', 'gm', 'gn', 'go', 'gp', 'gq', 'gr', 'gs', 'gt', 'gu', 'gv', 'gw', 'gx', 'gy', 'gz', 'ga', 'gb', 'gc', 'gd', 'ge', 'gf', 'gg', 'gh', 'gi', 'gj', 'gk', 'gl', 'gm', 'gn', 'go', 'gp', 'gq', 'gr', 'gs', 'gt', 'gu', 'gv', 'gw', 'gx', 'gy', 'gz', 'hi', 'hj', 'hk', 'hl', 'hm', 'hn', 'ho', 'hp', 'hq', 'hr', 'hs', 'ht', 'hu', 'hv', 'hw', 'hx', 'hy', 'hz', 'ha', 'hb', 'hc', 'hd', 'he', 'hf', 'hg', 'hh', 'hi', 'hj', 'hk', 'hl', 'hm', 'hn', 'ho', 'hp', 'hq', 'hr', 'hs', 'ht', 'hu', 'hv', 'hw', 'hx', 'hy', 'hz', 'ij', 'ik', 'il', 'im', 'in', 'io', 'ip', 'iq', 'ir', 'is', 'it', 'iu', 'iv', 'iw', 'ix', 'iy', 'iz', 'ia', 'ib', 'ic', 'id', 'ie', 'if', 'ig', 'ih', 'ii', 'ij', 'ik', 'il', 'im', 'in', 'io', 'ip', 'iq', 'ir', 'is', 'it', 'iu', 'iv', 'iw', 'ix', 'iy', 'iz', 'jk', 'jl', 'jm', 'jn', 'jo', 'jp', 'jq', 'jr', 'js', 'jt', 'ju', 'jv', 'jw', 'jx', 'jy', 'jz', 'ja', 'jb', 'jc', 'jd', 'je', 'jf', 'jg', 'jh', 'ji', 'jj', 'jk', 'jl', 'jm', 'jn', 'jo', 'jp', 'jq', 'jr', 'js', 'jt', 'ju', 'jv', 'jw', 'jx', 'jy', 'jz', 'kl', 'km', 'kn', 'ko', 'kp', 'kq', 'kr', 'ks', 'kt', 'ku', 'kv', 'kw', 'kx', 'ky', 'kz', 'ka', 'kb', 'kc', 'kd', 'ke', 'kf', 'kg', 'kh', 'ki', 'kj', 'kk', 'kl', 'km', 'kn', 'ko', 'kp', 'kq', 'kr', 'ks', 'kt', 'ku', 'kv', 'kw', 'kx', 'ky', 'kz', 'lm', 'ln', 'lo', 'lp', 'lq', 'lr', 'ls', 'lt', 'lu', 'lv', 'lw', 'lx', 'ly', 'lz', 'la', 'lb', 'lc', 'ld', 'le', 'lf', 'lg', 'lh', 'li', 'lj', 'lk', 'll', 'lm', 'ln', 'lo', 'lp', 'lq', 'lr', 'ls', 'lt', 'lu', 'lv', 'lw', 'lx', 'ly', 'lz', 'mn', 'mo', 'mp', 'mq', 'mr', 'ms', 'mt', 'mu', 'mv', 'mw', 'mx', 'my', 'mz', 'ma', 'mb', 'mc', 'md', 'me', 'mf', 'mg', 'mh', 'mi', 'mj', 'mk', 'ml', 'mm', 'mn', 'mo', 'mp', 'mq', 'mr', 'ms', 'mt', 'mu', 'mv', 'mw', 'mx', 'my', 'mz', 'no', 'np', 'nq', 'nr', 'ns', 'nt', 'nu', 'nv', 'nw', 'nx', 'ny', 'nz', 'na', 'nb', 'nc', 'nd', 'ne', 'nf', 'ng', 'nh', 'ni', 'nj', 'nk', 'nl', 'nm', 'nn', 'no', 'np', 'nq', 'nr', 'ns', 'nt', 'nu', 'nv', 'nw', 'nx', 'ny', 'nz', 'op', 'oq', 'or', 'os', 'ot', 'ou', 'ov', 'ow', 'ox', 'oy', 'oz', 'oa', 'ob', 'oc', 'od', 'oe', 'of', 'og', 'oh', 'oi', 'oj', 'ok', 'ol', 'om', 'on', 'oo', 'op', 'oq', 'or', 'os', 'ot', 'ou', 'ov', 'ow', 'ox', 'oy', 'oz', 'pq', 'pr', 'ps', 'pt', 'pu', 'pv', 'pw', 'px', 'py', 'pz', 'pa', 'pb', 'pc', 'pd', 'pe', 'pf', 'pg', 'ph', 'pi', 'pj', 'pk', 'pl', 'pm', 'pn', 'po', 'pp', 'pq', 'pr', 'ps', 'pt', 'pu', 'pv', 'pw', 'px', 'py', 'pz', 'qr', 'qs', 'qt', 'qu', 'qv', 'qw', 'qx', 'qy', 'qz', 'qa', 'qb', 'qc', 'qd', 'qe', 'qf', 'qg', 'qh', 'qi', 'qj', 'qk', 'ql', 'qm', 'qn', 'qo', 'qp', 'qq', 'qr', 'qs', 'qt', 'qu', 'qv', 'qw', 'qx', 'qy', 'qz', 'rs', 'rt', 'ru', 'rv', 'rw', 'rx', 'ry', 'rz', 'ra', 'rb', 'rc', 'rd', 're', 'rf', 'rg', 'rh', 'ri', 'rj', 'rk', 'rl', 'rm', 'rn', 'ro', 'rp', 'rq', 'rr', 'rs', 'rt', 'ru', 'rv', 'rw', 'rx', 'ry', 'rz', 'st', 'su', 'sv', 'sw', 'sx', 'sy', 'sz', 'sa', 'sb', 'sc', 'sd', 'se', 'sf', 'sg', 'sh', 'si', 'sj', 'sk', 'sl', 'sm', 'sn', 'so', 'sp', 'sq', 'sr', 'ss', 'st', 'su', 'sv', 'sw', 'sx', 'sy', 'sz', 'tu', 'tv', 'tw', 'tx', 'ty', 'tz', 'ta', 'tb', 'tc', 'td', 'te', 'tf', 'tg', 'th', 'ti', 'tj', 'tk', 'tl', 'tm', 'tn', 'to', 'tp', 'tq', 'tr', 'ts', 'tt', 'tu', 'tv', 'tw', 'tx', 'ty', 'tz', 'uv', 'uw', 'ux', 'uy', 'uz', 'ua', 'ub', 'uc', 'ud', 'ue', 'uf', 'ug', 'uh', 'ui', 'uj', 'uk', 'ul', 'um', 'un', 'uo', 'up', 'uq', 'ur', 'us', 'ut', 'uu', 'uv', 'uw', 'ux', 'uy', 'uz', 'vw', 'vx', 'vy', 'vz', 'va', 'vb', 'vc', 'vd', 've', 'vf', 'vg', 'vh', 'vi', 'vj', 'vk', 'vl', 'vm', 'vn', 'vo', 'vp', 'vq', 'vr', 'vs', 'vt', 'vu', 'vv', 'vw', 'vx', 'vy', 'vz', 'wx', 'wy', 'wz', 'wa', 'wb', 'wc', 'wd', 'we', 'wf', 'wg', 'wh', 'wi', 'wj', 'wk', 'wl', 'wm', 'wn', 'wo', 'wp', 'wq', 'wr', 'ws', 'wt', 'wu', 'wv', 'ww', 'wx', 'wy', 'wz', 'xy', 'xz', 'xa', 'xb', 'xc', 'xd', 'xe', 'xf', 'xg', 'xh', 'xi', 'xj', 'xk', 'xl', 'xm', 'xn', 'xo', 'xp', 'xq', 'xr', 'xs', 'xt', 'xu', 'xv', 'xw', 'xx', 'xy', 'xz', 'yz', 'ya', 'yb', 'yc', 'yd', 'ye', 'yf', 'yg', 'yh', 'yi', 'yj', 'yk', 'yl', 'ym', 'yn', 'yo', 'yp', 'yq', 'yr', 'ys', 'yt', 'yu', 'yv', 'yw', 'yx', 'yy', 'yz', 'za', 'zb', 'zc', 'zd', 'ze', 'zf', 'zg', 'zh', 'zi', 'zj', 'zk', 'zl', 'zm', 'zn', 'zo', 'zp', 'zq', 'zr', 'zs', 'zt', 'zu', 'zv', 'zw', 'zx', 'zy', 'zz', 'ab', 'ac', 'ad', 'ae', 'af', 'ag', 'ah', 'ai', 'aj', 'ak', 'al', 'am', 'an', 'ao', 'ap', 'aq', 'ar', 'as', 'at', 'au', 'av', 'aw', 'ax', 'ay', 'az', 'bc', 'bd', 'be', 'bf', 'bg', 'bh', 'bi', 'bj', 'bk', 'bl', 'bm', 'bn', 'bo', 'bp', 'bq', 'br', 'bs', 'bt', 'bu', 'bv', 'bw', 'bx', 'by', 'bz', 'cd', 'ce', 'cf', 'cg', 'ch', 'ci', 'cj', 'ck', 'cl', 'cm', 'cn', 'co', 'cp', 'cq', 'cr', 'cs', 'ct', 'cu', 'cv', 'cw', 'cx', 'cy', 'cz', 'de', 'df', 'dg', 'dh', 'di', 'dj', 'dk', 'dl', 'dm', 'dn', 'do', 'dp', 'dq', 'dr', 'ds', 'dt', 'du', 'dv', 'dw', 'dx', 'dy', 'dz', 'ef', 'eg', 'eh', 'ei', 'ej', 'ek', 'el', 'em', 'en', 'eo', 'ep', 'eq', 'er', 'es', 'et', 'eu', 'ev', 'ew', 'ex', 'ey', 'ez', 'fg', 'fh', 'fi', 'fj', 'fk', 'fl', 'fm', 'fn', 'fo', 'fp', 'fq', 'fr', 'fs', 'ft', 'fu', 'fv', 'fw', 'fx', 'fy', 'fz', 'gh', 'gi', 'gj', 'gk', 'gl', 'gm', 'gn', 'go', 'gp', 'gq', 'gr', 'gs', 'gt', 'gu', 'gv', 'gw', 'gx', 'gy', 'gz', 'hi', 'hj', 'hk', 'hl', 'hm', 'hn', 'ho', 'hp', 'hq', 'hr', 'hs', 'ht', 'hu', 'hv', 'hw', 'hx', 'hy', 'hz', 'ij', 'ik', 'il', 'im', 'in', 'io', 'ip', 'iq', 'ir', 'is', 'it', 'iu', 'iv', 'iw', 'ix', 'iy', 'iz', 'jk', 'jl', 'jm', 'jn', 'jo', 'jp', 'jq', 'jr', 'js', 'jt', 'ju', 'jv', 'jw', 'jx', 'jy', 'jz', 'kl', 'km', 'kn', 'ko', 'kp', 'kq', 'kr', 'ks', 'kt', 'ku', 'kv', 'kw', 'kx', 'ky', 'kz', 'lm', 'ln', 'lo', 'lp', 'lq', 'lr', 'ls', 'lt', 'lu', 'lv', 'lw', 'lx', 'ly', 'lz', 'mn', 'mo', 'mp', 'mq', 'mr', 'ms', 'mt', 'mu', 'mv', 'mw', 'mx', 'my', 'mz', 'no', 'np', 'nq', 'nr', 'ns', 'nt', 'nu', 'nv', 'nw', 'nx', 'ny', 'nz', 'op', 'oq', 'or', 'os', 'ot', 'ou', 'ov', 'ow', 'ox', 'oy', 'oz', 'pq', 'pr', 'ps', 'pt', 'pu', 'pv', 'pw', 'px', 'py', 'pz', 'qr', 'qs', 'qt', 'qu', 'qv', 'qw', 'qx', 'qy', 'qz', 'rs', 'rt', 'ru', 'rv', 'rw', 'rx', 'ry', 'rz', 'st', 'su', 'sv', 'sw', 'sx', 'sy', 'sz', 'tu', 'tv', 'tw', 'tx', 'ty', 'tz', 'uv', 'uw', 'ux', 'uy', 'uz', 'vw', 'vx', 'vy', 'vz', 'wx', 'wy', 'wz', 'xy', 'xz', 'yz']
class Topology:
  67class Topology:
  68  """Class to represent topology of a Tileable object.
  69
  70  NOTE: It is important that get_local_patch return the tileable elements and
  71  the translated copies in consistent sequence, i.e. if there are (say) four
  72  tiles in the unit, the local patch should be 1 2 3 4 1 2 3 4 1 2 3 4 ... and
  73  so on. This is because self.tiles[i % n_tiles] is frequently used to
  74  reference the base unit Tile which corresponds to self.tiles[i].
  75  """
  76
  77  tileable: Tileable
  78  """the Tileable on which the topology will be based."""
  79  tiles: list[Tile]
  80  """list of the Tiles in the topology. We use polygons returned by the
  81  tileable.get_local_patch method for these. That is the base tiles and 8
  82  adjacent copies (for a rectangular tiling), or 6 adjacent copies (for a
  83  hexagonal tiling)."""
  84  points: dict[int, Vertex]
  85  """dictionary of all points (vertices and corners) in the tiling, keyed by
  86  Vertex ID."""
  87  edges: dict[tuple[int,int], Edge]
  88  """dictionary of the tiling edges, keyed by Edge ID."""
  89  unique_tile_shapes: list[geom.Polygon]
  90  """a 'reference' tile shape one per tile shape (up to vertices, so two tiles
  91  might be the same shape, but one might have extra vertices induced by the
  92  tiling and hence is a different shape under this definition)."""
  93  dual_tiles: list[geom.Polygon]
  94  """list of geom.Polygons from which a dual tiling might be constructed."""
  95  n_tiles: int = 0
  96  """number of tiles in the base Tileable (retained for convenience)."""
  97  shape_groups: list[list[int]]
  98  """list of lists of tile IDs distinguished by shape and optionally tile_id"""
  99  tile_matching_transforms: list[tuple[float]]
 100  """shapely transform tuples that map tiles onto other tiles"""
 101  tile_transitivity_classes: list[tuple[int]]
 102  """list of lists of tile IDs in each transitivity class"""
 103  vertex_transitivity_classes: list[list[int]]
 104  """list of lists of vertex IDs in each transitivity class"""
 105  edge_transitivity_classes: list[list[tuple[int]]]
 106  """list of lists of edge IDs in each transitivity class"""
 107
 108  def __init__(
 109      self,
 110      unit: Tileable,
 111      ignore_tile_ids:bool = True,
 112    ) -> None:
 113    """Class constructor.
 114
 115    Args:
 116      unit (Tileable): the Tileable whose topology is required.
 117      ignore_tile_ids (bool): (EXPERIMENTAL) if True then only consider the tile
 118        shapes, not labels. If False consider any labels. Defaults to True.
 119
 120    """
 121    # Note that the order of these setup steps is fragile sometimes
 122    # not obviously so.
 123    self.tileable = unit # keep this for reference
 124    self.n_tiles = self.tileable.tiles.shape[0]
 125    self._initialise_points_into_tiles()
 126    self._setup_vertex_tile_relations()
 127    self._setup_edges()
 128    self._copy_base_tiles_to_patch()
 129    self._assign_vertex_and_edge_base_IDs()
 130    self._identify_distinct_tile_shapes(ignore_tile_ids)
 131    self._find_tile_transitivity_classes(ignore_tile_ids)
 132    self._find_vertex_transitivity_classes(ignore_tile_ids)
 133    self._find_edge_transitivity_classes(ignore_tile_ids)
 134    self.generate_dual()
 135
 136
 137  def __str__(self) -> str:
 138    """Return string representation of this Topology.
 139
 140    Returns:
 141      str: a message that recommends examining the tiles, points and edges
 142        attributes.
 143
 144    """
 145    return (f"""Topology of Tileable with {self.n_tiles} tiles.\n
 146            Examine .tiles, .points and .edges for more details.""")
 147
 148
 149  def __repr__(self) -> str:
 150    return str(self)
 151
 152
 153  def _initialise_points_into_tiles(self, debug:bool = False) -> None:
 154    """Set up dictionary of unique point locations and assign them to Tiles.
 155
 156    Args:
 157      debug (bool): if True prints useful debugging information.
 158
 159    """
 160    shapes = self.tileable.get_local_patch(r = 1, include_0 = True).geometry
 161    shapes = [tiling_utils.get_clean_polygon(s) for s in shapes]
 162    self.n_1_order_patch = len(shapes)
 163    labels = list(self.tileable.tiles.tile_id) * (len(shapes) // self.n_tiles)
 164    self.tiles = []
 165    self.points = {}
 166    for (i, shape), label in zip(enumerate(shapes), labels, strict = True):
 167      tile = Tile(i)
 168      tile.label = label
 169      tile.base_ID = tile.ID % self.n_tiles
 170      self.tiles.append(tile)
 171      tile.corners = []
 172      corners = tiling_utils.get_corners(shape, repeat_first = False)
 173      for c in corners:
 174        prev_vertex = None
 175        for p in reversed(self.points.values()):
 176          if c.distance(p.point) <= 2 * tiling_utils.RESOLUTION:
 177            # found an already existing vertex, so add to the tile and break
 178            prev_vertex = p
 179            tile.corners.append(prev_vertex)
 180            break
 181        if prev_vertex is None:
 182          # new vertex, add it to the topology dictionary and to the tile
 183          v = self.add_vertex(c)
 184          tile.corners.append(v)
 185          if debug:
 186            print(f"Added new Vertex {v} to Tile {i}")
 187
 188
 189  def _setup_vertex_tile_relations(self, debug:bool = False) -> None:
 190    """Determine relations between vertices and tiles.
 191
 192    In particular vertices along tile edges that are not yet included in their
 193    list of vertices are added. Meanwhile vertex lists of incident tiles and
 194    neighbours are set up.
 195
 196    Args:
 197      debug (bool): if True prints debugging information.
 198
 199    """
 200    # we do this for all tiles in the radius-1 local patch
 201    for tile in self.tiles:
 202      if debug:
 203        print(f"Checking for vertices incident on Tile {tile.ID}")
 204      corners = []
 205      # performance is much improved using the vertex IDs
 206      initial_corner_IDs = tile.get_corner_IDs()
 207      # we need the current shape (not yet set) to check for incident vertices
 208      shape = geom.Polygon([c.point for c in tile.corners])
 209      # get points incident on tile boundary, not already in the tile corners
 210      new_points = [v for v in self.points.values()
 211                    if v.ID not in initial_corner_IDs and
 212                    v.point.distance(shape) <= 2 * tiling_utils.RESOLUTION]
 213      # iterate over sides of the tile to see which the vertex is incident on
 214      for c1, c2 in zip(tile.corners, tile.corners[1:] + tile.corners[:1],
 215                        strict = True):
 216        all_points = [c1, c2]
 217        if len(new_points) > 0:
 218          if debug:
 219            print(f"{[v.ID for v in new_points]} incident on tile")
 220          ls = geom.LineString([c1.point, c2.point])
 221          to_insert = [v for v in new_points
 222                       if v.point.distance(ls) <= 2 * tiling_utils.RESOLUTION]
 223          if len(to_insert) > 0:
 224            # sort by distance along the side
 225            d_along = sorted([(ls.line_locate_point(v.point), v)
 226                              for v in to_insert], key = lambda x: x[0])
 227            to_insert = [v for d, v in d_along]
 228            all_points = all_points[:1] + to_insert + all_points[1:]
 229        corners.extend(all_points[:-1])
 230        for x1, x2 in zip(all_points[:-1], all_points[1:], strict = True):
 231          # x2 will add the tile and neigbour when we get to the next side
 232          # every vertex gets a turn!
 233          x1.add_tile(tile)
 234          x1.add_neighbour(x2.ID)
 235      tile.corners = corners
 236      tile.set_shape_from_corners()
 237
 238
 239  def _setup_edges(
 240      self,
 241      debug:bool = False,
 242    ) -> None:
 243    """Set up the tiling edges.
 244
 245    First vertices in the base tiles are classified as tiling vertices or not -
 246    only these can be classified reliably (e.g vertices on the perimeter are
 247    tricky).. Up to here all vertices have been considered tiling vertices.
 248
 249    Second edges are created by traversing tile corner lists. Edges are stored
 250    once only by checking for edges in the reverse direction already in the
 251    edges dictionary. Edge right and left tiles are initialised.
 252
 253    Third tile edge direction lists are initialised.
 254
 255    Args:
 256      debug (bool): if True print debug messages. Defaults to False.
 257
 258    """
 259    # classify vertices in the base tiles
 260    for tile in self.tiles[:self.n_tiles]:
 261      for v in tile.corners:
 262        v.is_tiling_vertex = len(v.neighbours) > 2
 263    if debug:
 264      print("Classified base tile vertices")
 265    self.edges = {}
 266    for tile in self.tiles:
 267      if debug:
 268        print(f"Adding edges from Tile {tile.ID}")
 269      tile.edges = []
 270      vertices = [v for v in tile.corners if v.is_tiling_vertex]
 271      # note that through here finding ints in lists is much faster than
 272      # finding Vertex objects, hence we use lists of IDs not Vertex objects
 273      if len(vertices) > 1:
 274        for v1, v2 in zip(vertices, vertices[1:] + vertices[:1], strict = True):
 275          corner_IDs = tile.get_corner_IDs()
 276          idx1 = corner_IDs.index(v1.ID)
 277          idx2 = corner_IDs.index(v2.ID)
 278          if idx1 < idx2:
 279            corners = corner_IDs[idx1:(idx2 + 1)]
 280          else:
 281            corners = corner_IDs[idx1:] + corner_IDs[:(idx2 + 1)]
 282          ID = (corners[0], corners[-1])
 283          if ID not in self.edges:
 284            # check that reverse direction edge is not present first
 285            r_ID = ID[::-1]
 286            if r_ID in self.edges:
 287              # if it is, then add it and set left_tile
 288              if debug:
 289                print(f"reverse edge {r_ID} found")
 290              e = self.edges[r_ID]
 291              e.left_tile = tile
 292              tile.edges.append(e)
 293            else:
 294              # we've found a new edge so make and add it
 295              if debug:
 296                print(f"adding new edge {corners}")
 297              e = self.add_edge([self.points[c] for c in corners])
 298              e.right_tile = tile
 299              tile.edges.append(e)
 300      # initialise the edge direction information in the tile
 301      tile.set_edge_directions()
 302
 303
 304  def _assign_vertex_and_edge_base_IDs(self) -> None:
 305    """Assign the base_ID attributes of vertices and edges.
 306
 307    These allow us to determine corrspondences between vertices and edges in
 308    the 'base' tiles in the Topology tileable, and those we have added at
 309    radius 1 for labelling and visualisation.
 310    """
 311    self._assign_vertex_base_IDs()
 312    self._assign_edge_base_IDs()
 313
 314
 315  def _assign_vertex_base_IDs(self) -> None:
 316    """Assign base_ID attribute of vertices."""
 317    # assign vertex base_ID frome the core tiles
 318    for tile0 in self.tiles[:self.n_tiles]:
 319      for v in tile0.get_corner_IDs():
 320        self.points[v].base_ID = v
 321    # assign others from their corresponding vertex in the core
 322    for t0 in self.tiles[:self.n_tiles]:
 323      for t1 in self.tiles[self.n_tiles:]:
 324        if t1.base_ID == t0.base_ID:
 325          for v0, v1 in zip(t0.corners, t1.corners, strict = True):
 326            v1.base_ID = v0.base_ID
 327
 328
 329  def _assign_edge_base_IDs(self) -> None:
 330    """Assign base_ID attribute of edges, based on base_IDs of vertices."""
 331    for tile0 in self.tiles[:self.n_tiles]:
 332      for e in tile0.get_edge_IDs():
 333        self.edges[e].base_ID = e
 334    for t0 in self.tiles[:self.n_tiles]:
 335      for t1 in self.tiles[self.n_tiles:]:
 336        if t1.base_ID == t0.base_ID:
 337          for e0, e1 in zip(t0.edges, t1.edges, strict = True):
 338            e1.base_ID = e0.base_ID
 339
 340
 341  def _copy_base_tiles_to_patch(self) -> None:
 342    """Copy attributes of base tiles to corresponding tiles in radius-1 patch.
 343
 344    This requires:
 345
 346    First, inserting any additional corners in the base tiles not found in the
 347    radius-1 tiles.
 348
 349    Second, any vertices in the base tiles that are NOT tiling vertices are
 350    applied to radius-1 tiles leading to merging of some edges.
 351    """
 352    # the number of tiles in the base + radius-1
 353    n_r1 = len(self.tiles)
 354    # first add any missing vertices to the non-base tiles
 355    # we add all the missing vertices before doing any merges
 356    for base in self.tiles[:self.n_tiles]:
 357      for other in self.tiles[base.ID:n_r1:self.n_tiles]:
 358        self._match_reference_tile_vertices(base, other)
 359    # then merge any edges that meet at a corner
 360    for base in self.tiles[:self.n_tiles]:
 361      for other in self.tiles[base.ID:n_r1:self.n_tiles]:
 362        self._match_reference_tile_corners(base, other)
 363
 364
 365  def _match_reference_tile_vertices(
 366      self,
 367      tile1:Tile,
 368      tile2:Tile,
 369    ) -> None:
 370    """Add vertices to tile2 so it matches tile1 adjusting edges as required.
 371
 372    This assumes the tiles are the same shape, but that tile2 may be missing
 373    some tiling vertices along some edges.
 374
 375    Args:
 376      tile1 (Tile): reference tile.
 377      tile2 (Tile): tile to change.
 378
 379    """
 380    to_add = len(tile1.corners) - len(tile2.corners)
 381    while to_add > 0:
 382      # find the reference x-y offset
 383      dxy = (tile2.centre.x - tile1.centre.x, tile2.centre.y - tile1.centre.y)
 384      for i, t1c in enumerate([c.point for c in tile1.corners]):
 385        t2c = tile2.corners[i % len(tile2.corners)].point
 386        if abs((t2c.x - t1c.x) - dxy[0]) > 10 * tiling_utils.RESOLUTION or \
 387           abs((t2c.y - t1c.y) - dxy[1]) > 10 * tiling_utils.RESOLUTION:
 388          # add vertex to t2 by copying the t1 vertex appropriately offset
 389          # note that this might alter the length of t2.corners
 390          v = self.add_vertex(geom.Point(t1c.x + dxy[0], t1c.y + dxy[1]))
 391          v.is_tiling_vertex = True
 392          old_edge, new_edges = tile2.insert_vertex_at(v, i)
 393          del self.edges[old_edge]
 394          for e in new_edges:
 395            self.edges[e.ID] = e
 396          to_add = to_add - 1
 397
 398
 399  def _match_reference_tile_corners(
 400      self,
 401      tile1:Tile,
 402      tile2:Tile,
 403    ) -> None:
 404    """Make vertices that are corners in tile1 corners in tile2.
 405
 406    Edges are merged as required.
 407
 408    Args:
 409        tile1 (Tile): reference tile.
 410        tile2 (Tile): tile to make match.
 411
 412    """
 413    vs_to_change = [
 414      vj for vi, vj in zip(tile1.corners, tile2.corners, strict = True)
 415      if not vi.is_tiling_vertex and vj.is_tiling_vertex]
 416    if len(vs_to_change) > 0:
 417      for v in vs_to_change:
 418        v.is_tiling_vertex = False
 419        # it's a corner not an edge so will have no more than 2 v.tiles
 420        old_edges, new_edge = v.tiles[0].merge_edges_at_vertex(v)
 421        for e in old_edges:
 422          del self.edges[e]
 423        self.edges[new_edge.ID] = new_edge
 424
 425  def _identify_distinct_tile_shapes(
 426      self,
 427      ignore_tile_id_labels:bool = True,
 428    ) -> None:
 429    """Identify unique tiles based on their symmetries and shapes.
 430
 431    At the same time assembles a list of the affine transforms under which
 432    matches occurs since these are potential symmetries of the tiling.
 433
 434    TODO: reimplement consideration of tile_id
 435
 436    Args:
 437      ignore_tile_id_labels (bool): if True only the shape of tiles matters; if
 438        False the tile_id label is also considered. Defaults to True.
 439
 440    """
 441    if ignore_tile_id_labels:
 442      matches = {}
 443      offsets = {}
 444      for tile in self.tiles[:self.n_tiles]:
 445        matches[tile.base_ID] = [tile.base_ID]
 446        matched = False
 447        s = Symmetries(tile.shape)
 448        for other in self.tiles[:self.n_tiles]:
 449          if other.ID > tile.ID:
 450            offset = s.get_corner_offset(other.shape)
 451            if offset is not None:
 452              offsets[tile.base_ID] = offset
 453              matches[tile.base_ID].append(other.base_ID)
 454              matched = True
 455        if not matched:
 456          offsets[tile.base_ID] = 0
 457      base_groups = list(
 458        nx.connected_components(nx.from_dict_of_lists(matches)))
 459      self.shape_groups = []
 460      for i, group in enumerate(base_groups):
 461        full_group = []
 462        for tile in self.tiles:
 463          if tile.base_ID in group:
 464            tile.shape_group = i
 465            tile.offset_corners(offsets[tile.base_ID])
 466            full_group.append(tile.ID)
 467        self.shape_groups.append(full_group)
 468    else:
 469      self.shape_groups = []
 470      for ti in self.tiles[:self.n_tiles]:
 471        self.shape_groups.append(
 472          [tj.ID for tj in self.tiles if tj.base_ID == ti.base_ID])
 473      for i, group in enumerate(self.shape_groups):
 474        for j in group:
 475          self.tiles[j].shape_group = i
 476
 477  def _find_tile_transitivity_classes(
 478      self,
 479      ignore_tile_id_labels:bool = True,
 480    ) -> None:
 481    """Find tiles equivalent under symmetries.
 482
 483    Also update the tile_matching_transforms attribute to contain only those
 484    transforms that pass this test.
 485
 486    Args:
 487      ignore_tile_id_labels (bool): if True then consider only shapes; if False
 488        also consider labels of tiles. Defaults to True.
 489
 490    """
 491    self.tile_matching_transforms = \
 492      self.get_potential_symmetries(ignore_tile_id_labels)
 493    if ignore_tile_id_labels:
 494      base_tiles = self.tiles[:self.n_tiles]
 495      # it is quicker (should be!) to only do within shape group tests
 496      # often there is only one when it will make no difference
 497      by_group_equivalent_tiles = []
 498      # maintain a set of transforms still potentially tiling symmetries
 499      poss_transforms = set(self.tile_matching_transforms.keys())
 500      # and a dictionary of booleans tracking which transforms are still valid
 501      eq_under_transform = dict.fromkeys(poss_transforms, True)
 502      for g, _ in enumerate(self.shape_groups):
 503        by_group_equivalent_tiles.append(set())
 504        source_tiles = [tile for tile in base_tiles if tile.shape_group == g]
 505        target_tiles = [tile for tile in self.tiles if tile.shape_group == g]
 506        for tr in poss_transforms:
 507          transform = self.tile_matching_transforms[tr].transform
 508          matched_tiles = {}
 509          eq_under_transform[tr] = True
 510          for source_tile in source_tiles:
 511            matched_tile_id = self._match_geoms_under_transform(
 512              source_tile, target_tiles, transform)
 513            if matched_tile_id == -1:
 514              eq_under_transform[tr] = False
 515              break
 516            matched_tiles[source_tile.ID] = matched_tile_id # actually a base_ID
 517          if eq_under_transform[tr]:
 518            for k, v in matched_tiles.items():
 519              # here we record the transform, in case it is later invalidated
 520              by_group_equivalent_tiles[g].add((tr, k, v))
 521        # remove valid transforms that didn't make it through this group
 522        poss_transforms = {t for t, x in eq_under_transform.items() if x}
 523      # compile equivalences from all groups made under still valid transforms
 524      # a dict of sets so singletons aren't lost in finding connected components
 525      equivalents = {i: set() for i in range(self.n_tiles)}
 526      for group_equivalents in by_group_equivalent_tiles:
 527        for (tr, tile_i, tile_j) in group_equivalents:
 528          if tr in poss_transforms:
 529            equivalents[tile_i].add(tile_j)
 530      self.tile_matching_transforms = {
 531        k: v for k, v in self.tile_matching_transforms.items()
 532        if k in poss_transforms}
 533      self.tile_transitivity_classes = []
 534      equivalents = nx.connected_components(nx.from_dict_of_lists(equivalents))
 535      for c, base_IDs in enumerate(equivalents):
 536        transitivity_class = []
 537        for tile in self.tiles:
 538          if tile.base_ID in base_IDs:
 539            transitivity_class.append(tile.ID)
 540            tile.transitivity_class = c
 541        self.tile_transitivity_classes.append(transitivity_class)
 542    else:
 543      # transitivity classes are just the individual tiles
 544      self.tile_transitivity_classes = []
 545      for i, tile in enumerate(self.tiles):
 546        tile.transitivity_class = tile.base_ID
 547        if i < self.n_tiles:
 548          self.tile_transitivity_classes.append([tile.ID])
 549        else:
 550          self.tile_transitivity_classes[tile.base_ID].append(tile.ID)
 551
 552
 553  def get_potential_symmetries(
 554      self,
 555      ignore_tile_id_labels:bool = True,
 556    ) -> dict[int, Transform]:
 557    """Assemble potential symmetries from symmetries of prototile and tiles.
 558
 559    Also remove any duplicates that result. The result is assigned to the
 560    tile_matching_transforms attribute.
 561
 562    TODO: consider retaining the Symmetry objects as these carry additional
 563    information that might facilitate labelling under a limited number of the
 564    symmetries not all of them.
 565
 566    Returns:
 567      dict[int, tuple[float]]: dictionary of the symmetries (transforms
 568        actually) in shapely affine transform 6-tuple format.
 569
 570    """
 571    self.tile_matching_transforms = {
 572      k: Transform("translation", 0, geom.Point(0, 0), v,
 573                   tiling_utils.get_translation_transform(v[0], v[1]))
 574      for k, v in enumerate(self.tileable.get_vectors())}
 575    if ignore_tile_id_labels:
 576      n_symmetries = len(self.tile_matching_transforms)
 577      ptile = self.tileable.prototile.loc[0, "geometry"]
 578      for tr in ShapeMatcher(ptile).get_polygon_matches(ptile):
 579        if tr.transform_type not in ["identity", "translation"]:
 580          self.tile_matching_transforms[n_symmetries] = tr
 581          n_symmetries = n_symmetries + 1
 582      for tile in self.tiles[:self.n_tiles]:
 583        for tr in ShapeMatcher(tile.shape).get_polygon_matches(tile.shape):
 584          if tr.transform_type not in ["identity", "translation"]:
 585            self.tile_matching_transforms[n_symmetries] = tr
 586            n_symmetries = n_symmetries + 1
 587      for tile in self.tiles[:self.n_tiles]:
 588        sm = ShapeMatcher(tile.shape)
 589        transforms = [sm.get_polygon_matches(self.tiles[i].shape)
 590          for i in self.shape_groups[tile.shape_group] if i < self.n_tiles]
 591        for tr in itertools.chain(*transforms):
 592          if tr.transform_type not in ["identity", "translation"]:
 593            self.tile_matching_transforms[n_symmetries] = tr
 594            n_symmetries = n_symmetries + 1
 595      self.tile_matching_transforms = self._remove_duplicate_symmetries(
 596        self.tile_matching_transforms)
 597    return self.tile_matching_transforms
 598
 599
 600  def _remove_duplicate_symmetries(
 601      self,
 602      transforms:dict[int,Transform],
 603    ) -> dict[int,Transform]:
 604    """Filter list of shapely affine transforms to remove duplicates.
 605
 606    Args:
 607      transforms (dict[int,Transform]): dictionary of Transforms to filter.
 608
 609    Returns:
 610      dict[int,Transform]: the filtered dictionary with duplicates removed.
 611
 612    """
 613    uniques = {}
 614    for k, v in transforms.items():
 615      already_exists = False
 616      for u in uniques.values():
 617        already_exists = np.allclose(
 618          v.transform, u.transform, atol = 1e-4, rtol = 1e-4)
 619        if already_exists:
 620          break
 621      if not already_exists:
 622        uniques[k] = v
 623    return uniques
 624
 625
 626  def _find_vertex_transitivity_classes(
 627      self,
 628      ignore_tile_id_labels:bool = True,
 629    ) -> None:
 630    """Find vertex transitivity classes.
 631
 632    This function checks which vertices align with which others under transforms
 633    in the tile_matching_transforms attribute. The process need only determine
 634    the classes for vertices in the core tileable.tiles, then assign those to
 635    all vertices by matched base_ID.
 636    """
 637    if ignore_tile_id_labels:
 638      equivalent_vertices = defaultdict(set)
 639      base_vertices = [v for v in
 640                      self.vertices_in_tiles(self.tiles[:self.n_tiles])
 641                      if v.is_tiling_vertex]
 642      for transform in self.tile_matching_transforms.values():
 643        for v in base_vertices:
 644          equivalent_vertices[v.ID].add(v.ID)
 645          match_ID = self._match_geoms_under_transform(
 646            v, base_vertices, transform.transform)
 647          if match_ID != -1:
 648            equivalent_vertices[v.ID].add(match_ID)
 649      equivalent_vertices = self._get_exclusive_supersets(
 650        [tuple(sorted(s)) for s in equivalent_vertices.values()])
 651      self.vertex_transitivity_classes = defaultdict(list)
 652      for c, vclass in enumerate(equivalent_vertices):
 653        for v in self.points.values():
 654          if v.base_ID in vclass:
 655            v.transitivity_class = c
 656            self.vertex_transitivity_classes[c].append(v.ID)
 657      self.vertex_transitivity_classes = list(
 658        self.vertex_transitivity_classes.values())
 659      # label vertices based on their transitivity class
 660      for v in self.points.values():
 661        if v.is_tiling_vertex:
 662          v.label = LABELS[v.transitivity_class]
 663    else:
 664      self.vertex_transitivity_classes = defaultdict(list)
 665      for v in self.points.values():
 666        if v.is_tiling_vertex:
 667          self.vertex_transitivity_classes[v.base_ID].append(v.ID)
 668          v.transitivity_class = v.base_ID
 669      self.vertex_transitivity_classes = list(
 670        self.vertex_transitivity_classes.values())
 671      for v in self.points.values():
 672        if v.is_tiling_vertex:
 673          v.label = LABELS[v.transitivity_class]
 674
 675
 676  def _find_edge_transitivity_classes(
 677      self,
 678      ignore_tile_id_labels:bool = True,
 679    ) -> None:
 680    """Find edge transitivity classes.
 681
 682    This function works by checking which edges align with which others under
 683    transforms in the tile_matching_transforms attribute. The process need only
 684    determine the classes for edges in the core tileable.tiles, then assign
 685    those to all edges by matched base_ID.
 686
 687    TODO: Note that this code is identical to the vertex transitivity code
 688    so it might make sense to merge.
 689    """
 690    if ignore_tile_id_labels:
 691      equivalent_edges = defaultdict(set)
 692      base_edges = self.edges_in_tiles(self.tiles[:self.n_tiles])
 693      for transform in self.tile_matching_transforms.values():
 694        for e in base_edges:
 695          equivalent_edges[e.ID].add(e.ID)
 696          match_id = self._match_geoms_under_transform(
 697            e, base_edges, transform.transform)
 698          if match_id != -1:
 699            equivalent_edges[e.ID].add(match_id)
 700      equivalent_edges = self._get_exclusive_supersets(
 701        [tuple(sorted(s)) for s in equivalent_edges.values()])
 702      self.edge_transitivity_classes = defaultdict(list)
 703      for c, eclass in enumerate(equivalent_edges):
 704        for e in self.edges.values():
 705          if e.base_ID in eclass:
 706            e.transitivity_class = c
 707            self.edge_transitivity_classes[c].append(e.ID)
 708      self.edge_transitivity_classes = list(
 709        self.edge_transitivity_classes.values())
 710      # label edges based on their transitivity class
 711      for e in self.edges.values():
 712        e.label = labels[e.transitivity_class]
 713    else:
 714      self.edge_transitivity_classes = defaultdict(list)
 715      for e in self.edges.values():
 716        self.edge_transitivity_classes[e.base_ID].append(e.ID)
 717      self.edge_transitivity_classes = list(
 718        self.edge_transitivity_classes.values())
 719      for i, eclass in enumerate(self.edge_transitivity_classes):
 720        for e in eclass:
 721          self.edges[e].transitivity_class = i
 722          self.edges[e].label = labels[i]
 723
 724
 725  def _match_geoms_under_transform(
 726      self,
 727      geom1:Tile|Vertex|Edge,
 728      geoms2:list[Tile|Vertex|Edge],
 729      transform:tuple[float,...],
 730    ) -> int|tuple[int]:
 731    """Determine if a geometry maps onto any in a patch under supplied symmetry.
 732
 733    Args:
 734      geom1 (Tile|Vertex|Edge): element whose geometry we want to match.
 735      geoms2 (list[Tile|Vertex|Edge]): set of elements among which a
 736        match is sought.
 737      transform (tuple[float]): shapely affine transform 6-tuple to apply.
 738
 739    Returns:
 740      int|tuple[int,int]: ID of the element in patch that matches the geom1
 741        element under the transform if one exists, otherwise returns -1. For
 742        edges note that the ID is a tuple.
 743
 744    """
 745    match_id = -1
 746    for geom2 in geoms2:
 747      if isinstance(geom1, Tile):
 748        # an area of intersection based test
 749        match = self.polygon_matches(
 750          affine.affine_transform(geom1.shape, transform), geom2.shape)
 751      elif isinstance(geom1, Vertex):
 752        # distance test
 753        match = affine.affine_transform(geom1.point, transform).distance(
 754          geom2.point) <= 10 * tiling_utils.RESOLUTION
 755      else: # must be an Edge
 756        # since edges _should not_ intersect this test should work in
 757        # lieu of a more complete point by point comparison
 758        c1 = geom1.get_geometry().centroid
 759        c2 = geom2.get_geometry().centroid
 760        match = affine.affine_transform(c1, transform) \
 761          .distance(c2) <= 10 *tiling_utils.RESOLUTION
 762      if match:
 763        return geom2.base_ID
 764    return match_id
 765
 766
 767  def _get_exclusive_supersets(
 768      self, sets:list[Iterable]) -> list[Iterable]:
 769    """Return sets of elements not found in the same set among those supplied.
 770
 771    The supplied sets share elements, i.e., they are non-exclusives sets. The
 772    returned sets are exclusive: each element will only appear in one of the
 773    sets in the returned list. This is accomplished using networkx's
 774    connected components applied to a graph where each intersection between two
 775    sets is an edge.
 776
 777    Args:
 778        sets (list[Iterable]): list of lists of possibly overlapping sets.
 779
 780    Returns:
 781      list[Iterable]: list of lists that include all the original
 782        elements without overlaps.
 783
 784    """
 785    overlaps = []
 786    for i, si in enumerate(sets):
 787      s1 = set(si)
 788      for j, sj in enumerate(sets):
 789        s2 = set(sj)
 790        if len(s1 & s2) > 0:
 791          overlaps.append((i, j))
 792    G = nx.from_edgelist(overlaps)
 793    result = []
 794    for component in nx.connected_components(G):
 795      s = set()
 796      for i in component:
 797        s = s.union(sets[i])
 798      result.append(tuple(s))
 799    return result
 800
 801
 802  def vertices_in_tiles(self, tiles:list[Tile]) -> list[Vertex]:
 803    """Get vertices incident on tiles in supplied list.
 804
 805    Args:
 806      tiles (list[Tile]): tiles whose vertices are required.
 807
 808    Returns:
 809      list[Vertex]: the required vertices.
 810
 811    """
 812    vs = set()
 813    for tile in tiles:
 814      vs = vs.union(tile.get_corner_IDs())
 815    return [self.points[v] for v in vs]
 816
 817
 818  def edges_in_tiles(self, tiles:list[Tile]) -> list[Edge]:
 819    """Get edges that are part of the boundary of tiles in supplied list.
 820
 821    Args:
 822      tiles (list[Tile]): tiles whose edges are required.
 823
 824    Returns:
 825      list[Edge]: the required edges.
 826
 827    """
 828    es = set()
 829    for tile in tiles:
 830      es = es.union(tile.get_edge_IDs())
 831    return [self.edges[e] for e in es]
 832
 833
 834  def generate_dual(self) -> list[geom.Polygon]:
 835    """Create the dual tiiing for the tiling of this Topology.
 836
 837    TODO: make this a viable replacement for the existing dual tiling
 838    generation.
 839
 840    TODO: also need to ensure that this finds a set of dual tiles that exhaust
 841    the plane...
 842
 843    Returns:
 844      list[geom.Polygon]: a list of polygon objects.
 845
 846    """
 847    for v in self.points.values():
 848      v.clockwise_order_incident_tiles()
 849    self.dual_tiles = {}
 850    base_id_sets = defaultdict(list)
 851    for v in self.points.values():
 852      base_id_sets[v.base_ID].append(v.ID)
 853    minimal_set = [self.points[min(s)] for s in base_id_sets.values()]
 854    for v in minimal_set:
 855    # for v in self.points.values():
 856      if v.is_interior() and len(v.tiles) > 2:
 857        self.dual_tiles[v.ID] = \
 858          geom.Polygon([t.centre for t in v.tiles])
 859
 860
 861  def get_dual_tiles(self) -> gpd.GeoDataFrame:
 862    """Return dual tiles as GeoDataFrame."""
 863    n = len(self.dual_tiles)
 864    return gpd.GeoDataFrame(
 865      data = {"tile_id": list(self.tileable.tiles.tile_id)[:n]},
 866      geometry = gpd.GeoSeries(self.dual_tiles.values()),
 867      crs = self.tileable.crs)
 868
 869
 870  def add_vertex(self, pt:geom.Point) -> Vertex:
 871    """Add and return Vertex at the specified point location.
 872
 873    No attempt is made to ensure Vertex IDs are an unbroken sequence: a new ID
 874    is generated one greater than the existing highest ID. IDs will usually be
 875    an unbroken sequence up to removals when geometry transformations are
 876    applied.
 877
 878    Args:
 879      pt (geom.Point): point location of the Vertex.
 880
 881    Returns:
 882      Vertex: the added Vertex object.
 883
 884    """
 885    n = 0 if len(self.points) == 0 else max(self.points.keys()) + 1
 886    v = Vertex(pt, n)
 887    self.points[n] = v
 888    return v
 889
 890
 891  def add_edge(self, vs:list[Vertex]) -> Edge:
 892    """Create an Edge from the suppled vertices and return it.
 893
 894    The new Edge is added to the edges dictionary. Edges are self indexing by
 895    the IDs of their end Vertices.
 896
 897    Args:
 898      vs (list[Vertex]): list of Vertices in the Edge to be created.
 899
 900    Returns:
 901        Edge: the added Edge.
 902
 903    """
 904    e = Edge(vs)
 905    self.edges[e.ID] = e
 906    return e
 907
 908
 909  def polygon_matches(
 910      self,
 911      geom1:geom.Polygon,
 912      geom2:geom.Polygon,
 913    ) -> bool:
 914    """Test if supplied polygons match geometrically.
 915
 916    Tests for equality of area, and equality of their area of overlap to their
 917    shared area, i.e. Area1 == Area2 == (Area 1 intersection 2).
 918
 919    Args:
 920      geom1 (geom.Polygon): first polygon.
 921      geom2 (geom.Polygon): second polygon.
 922
 923    Returns:
 924      bool: True if the polygons are the same, False otherwise.
 925
 926    """
 927    a, b = geom1.area, geom2.area
 928    return bool(
 929      np.isclose(a, b,
 930                 rtol = tiling_utils.RESOLUTION * 100,
 931                 atol = tiling_utils.RESOLUTION * 100) and
 932      np.isclose(a, geom1.intersection(geom2).area,
 933                 rtol = tiling_utils.RESOLUTION * 100,
 934                 atol = tiling_utils.RESOLUTION * 100))
 935
 936
 937  def _get_tile_geoms(self) -> gpd.GeoDataFrame:
 938    """Return GeoDataFrame of the tiles.
 939
 940    Returns:
 941      gpd.GeoDataFrame: the tiles as a GeoDataFrame.
 942
 943    """
 944    return gpd.GeoDataFrame(
 945      data = {"transitivity_class": [t.transitivity_class for t in self.tiles]},
 946      geometry = gpd.GeoSeries([t.shape for t in self.tiles]))
 947
 948
 949  def _get_tile_centre_geoms(self) -> gpd.GeoSeries:
 950    """Return the centre of tiles as a GeoDataFrame.
 951
 952    Returns:
 953      gpd.GeoDataFrame: the tiles centres.
 954
 955    """
 956    return gpd.GeoSeries([t.centre for t in self.tiles])
 957
 958
 959  def _get_point_geoms(self) -> gpd.GeoSeries:
 960    """Return tiling vertices and tile corners as a GeoDataFrame of Points.
 961
 962    Returns:
 963      gpd.GeoDataFrame: corners and vertices of the tiling.
 964
 965    """
 966    return gpd.GeoSeries([v.point for v in self.points.values()])
 967
 968
 969  def _get_vertex_geoms(self) -> gpd.GeoSeries:
 970    """Return tiling vertices (not corners) as a GeoDataFrame of Points.
 971
 972    Returns:
 973      gpd.GeoDataFrame: vertices of the tiling.
 974
 975    """
 976    return gpd.GeoSeries([v.point for v in self.points.values()
 977                          if v.is_tiling_vertex])
 978
 979
 980  def _get_edge_geoms(
 981      self,
 982      offset:float = 0.0,
 983    ) -> gpd.GeoSeries:
 984    """Return tiling edges a GeoSeries of LineStrings optionally offset.
 985
 986    Offsetting the edges allows their direction to be seen when visualised.
 987
 988    Returns:
 989      gpd.GeoDataFrame: edges of the tiling.
 990
 991    """
 992    return gpd.GeoSeries([e.get_topology().parallel_offset(offset)
 993                          for e in self.edges.values()])
 994
 995
 996  def plot(
 997      self,
 998      show_original_tiles: bool = True,
 999      show_tile_centres: bool = False,
1000      show_vertex_labels: bool = True,
1001      show_vertex_ids: bool = False,
1002      show_edges: bool = True,
1003      offset_edges: bool = True,
1004      show_edge_labels:bool = False,
1005      show_dual_tiles: bool = False,
1006    ) -> plt.Axes:
1007    """Delegate plotting of requested elements and return plt.Axes.
1008
1009    Args:
1010      show_original_tiles (bool, optional): if True show the tiles. Defaults to
1011        True.
1012      show_tile_centres (bool, optional): if True show tile centres with upper
1013        case alphabetical labels. Defaults to False.
1014      show_vertex_labels (bool, optional): if True show tiling vertices labelled
1015        to show their equivalence classes. Defaults to True.
1016      show_vertex_ids (bool, optional): if True show vertex IDs (i.e., sequence
1017        numbers) which is useful for debugging. Defaults to False.
1018      show_edges (bool, optional): if True show tiling edges (not tile sides).
1019        Defaults to True.
1020      offset_edges (bool, optional): if True offset edges a little from and
1021        parallel to their geometric position. Defaults to True.
1022      show_edge_labels (bool, optional): if true show lower case alphabetical
1023        labels identifying edge equivalence classes. Defaults to False.
1024      show_dual_tiles (bool, optional): if True show a candidate set of dual
1025        tiles as an overlay. Defaults to False.
1026
1027    Returns:
1028      plt.Axes: a plot of the Topology as requested.
1029
1030    """
1031    fig = plt.figure(figsize = (10, 10))
1032    ax = fig.add_axes(111)
1033    extent = gpd.GeoSeries([t.shape for t in self.tiles]).total_bounds
1034    dist = max([extent[2] - extent[0], extent[3] - extent[1]]) / 100
1035    if show_original_tiles:
1036      self._plot_tiles(ax)
1037    if show_tile_centres:
1038      self._plot_tile_centres(ax)
1039    if show_vertex_labels:
1040      self._plot_vertex_labels(ax, show_vertex_ids)
1041    if show_edge_labels or show_edges:
1042      self._plot_edges(ax, show_edges, show_edge_labels, dist, offset_edges)
1043    if show_dual_tiles:
1044      self._plot_dual_tiles(ax, dist)
1045    plt.axis("off")
1046    return ax
1047
1048
1049  def _plot_tiles(self, ax:plt.Axes) -> plt.Axes:
1050    """Plot Topology's tiles on supplied Axes.
1051
1052    Tiles are coloured by equivalence class.
1053
1054    Args:
1055      ax (plt.Axes): Axes on which to plot.
1056
1057    Returns:
1058      plt.Axes: the Axes.
1059
1060    """
1061    self._get_tile_geoms().plot(column = "transitivity_class",
1062      ax = ax, ec = "#444444", lw = 0.5, alpha = 0.25, cmap = "Greys")
1063    return ax
1064
1065
1066  def _plot_tile_centres(self, ax:plt.Axes) -> plt.Axes:
1067    """Print tile transitivity class at each tile centroid.
1068
1069    Args:
1070      ax (plt.Axes): Axes on which to plot.
1071
1072    Returns:
1073      plt.Axes: the Axes.
1074
1075    """
1076    for _, tile in enumerate(self.tiles):
1077      ax.annotate(tile.transitivity_class, xy = (tile.centre.x, tile.centre.y),
1078                  ha = "center", va = "center")
1079    return ax
1080
1081
1082  def _plot_vertex_labels(
1083      self,
1084      ax:plt.Axes,
1085      show_vertex_ids:bool = False,
1086    ) -> plt.Axes:
1087    """Plot either the Vertex transitivity class label or its sequential ID.
1088
1089    Args:
1090      ax (plt.Axes): Axes on which to plot.
1091      show_vertex_ids (bool, optional): If True plots the ID, else plots the
1092        transitivity class. Defaults to False.
1093
1094    Returns:
1095      plt.Axes: the Axes.
1096
1097    """
1098    for v in self.points.values():
1099      ax.annotate(v.ID if show_vertex_ids else v.label,
1100                  xy = (v.point.x, v.point.y), color = "k",
1101                  ha = "center", va = "center")
1102    return ax
1103
1104
1105  def _plot_edges(
1106      self,
1107      ax:plt.Axes,
1108      show_edges:bool = False,
1109      show_edge_labels:bool = False,
1110      dist:float = 0.0,
1111      offset_edges:bool = True,
1112    ) -> plt.Axes:
1113    """Plot edges, including an offset if specified and labels if requested.
1114
1115    Can also be called to only plot the labels.
1116
1117    Args:
1118      ax (plt.Axes): Axes on which to plot.
1119      show_edges (bool, optional): if True includes the edges as. a dotted blue
1120        line, optionally offset (for clarity) from the tile boundary. Defaults
1121        to False.
1122      show_edge_labels (bool, optional): if True shows an edge label. Defaults
1123        to False.
1124      dist (float, optional): a distance by which to offset the dotted line for
1125        the edge from the tile boundary. Defaults to 0.0.
1126      offset_edges (bool, optional): if True applies the edge drawing offset,
1127        if False the edge is drawn as a single line segment between its end
1128        vertices (and may not align with the sides of tiles. Defaults to True.
1129
1130    Returns:
1131      plt.Axes: the Axes.
1132
1133    """
1134    if show_edges:
1135      edges = self._get_edge_geoms(dist if offset_edges else 0)
1136      edges.plot(ax = ax, color = "dodgerblue", ls = ":")
1137    else:
1138      edges = [e.get_geometry() for e in self.edges.values()]
1139    if show_edge_labels:
1140      for ls, e in zip(edges, self.edges.values(), strict = True):
1141        c = ls.centroid
1142        ax.annotate(e.label, xy = (c.x, c.y), color = "k",
1143                    ha = "center", va = "center")
1144    return ax
1145
1146  def _plot_dual_tiles(self, ax:plt.Axes,
1147                       dist:float = 0.0) -> plt.Axes:
1148    gpd.GeoSeries(self.dual_tiles).buffer(
1149      -dist / 4, join_style = "mitre", cap_style = "square").plot(
1150        ax = ax, fc = "g", alpha = 0.25)
1151    return ax
1152
1153
1154  def plot_tiling_symmetries(
1155      self,
1156      **kwargs:float,
1157    ) -> None:
1158    """Plot the symmetries of Topology's tiling.
1159
1160    Most of the work here is delegated to `_plot_tiling_symmetry` which is run
1161    once per symmetry on a grid of plt.Axes built by this function.
1162
1163    Args:
1164      kwargs: passed through to `_plot_tiling_symmetry`
1165
1166    """
1167    n = len(self.tile_matching_transforms)
1168    nc = int(np.ceil(np.sqrt(n)))
1169    nr = int(np.ceil(n / nc))
1170    fig = plt.figure(figsize = (12, 12 * nr / nc))
1171    for i, tr in enumerate(self.tile_matching_transforms.values()):
1172      ax = fig.add_subplot(nr, nc, i + 1)
1173      self._plot_tiling_symmetry(tr, ax, **kwargs)
1174
1175
1176  def _plot_tiling_symmetry(
1177      self,
1178      transform:Transform,
1179      ax:plt.Axes,
1180      **kwargs:float,
1181    ) -> None:
1182    """Plot the supplied Transform on the supplied plt.Axes.
1183
1184    Args:
1185        transform (Transform): the Transform to plot.
1186        ax (plt.Axes): the Axes on which to plot.
1187        kwargs: passed through to plt.plot functions.
1188
1189    """
1190    tiles = gpd.GeoSeries([t.shape for t in self.tiles])
1191    base_tiles = tiles[:self.n_tiles]
1192    tiles.plot(ax = ax, fc = "k", alpha = .15, ec = "k", lw = .5)
1193    base_tiles.plot(ax = ax, fc = "#00000000", ec = "w", lw = 1, zorder = 2)
1194    transformed_base_tiles = gpd.GeoSeries(
1195      [transform.apply(g) for g in base_tiles])
1196    transformed_base_tiles.plot(ax = ax, fc = "k", alpha = .2, lw = 0, ec = "k")
1197    transform.draw(ax, **kwargs) # delegate to Transform.draw
1198    plt.axis("off")
1199
1200
1201  def transform_geometry(
1202      self,
1203      new_topology:bool,
1204      apply_to_tiles:bool,
1205      selector:str,
1206      transform_type:str,
1207      **kwargs:float) -> Topology:
1208    r"""Get a new Topology by applying specified transformation.
1209
1210    A transformation specified by `transform_type` and keyword arguments is
1211    applied to elements in the Topology whose labels match the selector
1212    parameter. The transform is optionally applied to update tiles and
1213    optionally requests a new Topology object.
1214
1215    Implemented in this way so that transformations can be applied one at a time
1216    without creating an intermediate set of new tiles, which may be invalid and
1217    fail. So, if you wish to apply (say) 3 transforms and generate a new
1218    Topology leaving the existing one intact:
1219
1220        new_topo = old_topo.transform_geometry(True,  False, "a", ...) \
1221                           .transform_geometry(False, False, "B", ...) \
1222                           .transform_geometry(False, True,  "C", ...)
1223
1224    The first transform requests a new Topology, subsequent steps do not, and it
1225    is only the last step which attempts to create the new tile polygons.
1226
1227    **kwargs supply named parameters for the requested transformation.
1228
1229    Args:
1230      new_topology (bool): if True returns a new Topology object, else returns
1231        the current Topology modified.
1232      apply_to_tiles (bool): if True attempts to create new Tiles after the
1233        transformation has been applied. Usually set to False, unless the last
1234        transformation in a pipeline, to avoid problems of topologically invalid
1235        tiles at intermediate steps.
1236      selector (str): label of elements to which to apply the transformation.
1237        Note that all letters in the supplied string are checked, so you can
1238        use e.g. "abc" to apply a transformation to edges labelled "a", "b" or
1239        "c", or "AB" for vertices labelled "A" or "B".
1240      transform_type (str): name of the type of transformation requested.
1241        Currently supported are `zigzag_edge`, `rotate_edge`, `scale_edge`,
1242        `push_vertex`, and `nudge_vertex`. Keyword arguments for each are
1243        documented in the corresponding methods.
1244      kwargs: contains any needed arguments of the requested transform_type.
1245
1246    Returns:
1247      Topology: if new_topology is True a new Topology based on this one with
1248        after transformation, if False this Topology is returned after the
1249        transformation.
1250
1251    """
1252    print("CAUTION: new Topology will probably not be correctly labelled. "
1253          "To build a correct Topology, extract the tileable attribute and "
1254          "rebuild Topology from that.")
1255    # pickle seems to be more reliable than copy.deepcopy here
1256    topo = (pickle.loads(pickle.dumps(self))   # noqa: S301
1257            if new_topology
1258            else self)
1259    transform_args = topo.get_kwargs(getattr(topo, transform_type), **kwargs)
1260    match transform_type:
1261      case "zigzag_edge":
1262        for e in topo.edges.values():
1263          if e.label in selector:
1264            topo.zigzag_edge(e, **transform_args)
1265      case "rotate_edge":
1266        for e in topo.edges.values():
1267          if e.label in selector:
1268            topo.rotate_edge(e, **transform_args)
1269      case "scale_edge":
1270        for e in topo.edges.values():
1271          if e.label in selector:
1272            topo.scale_edge(e, **transform_args)
1273      case "push_vertex":
1274        pushes = {}
1275        for v in topo.vertices_in_tiles(topo.tiles[:topo.n_tiles]):
1276          if v.label in selector:
1277            pushes[v.base_ID] = topo.push_vertex(v, **transform_args)
1278        for base_ID, (dx, dy) in pushes.items():
1279          for v in [v for v in topo.points.values() if v.base_ID == base_ID]:
1280            v.point = affine.translate(v.point, dx, dy)
1281      case "nudge_vertex":
1282         for v in topo.points.values():
1283          if v.label in selector:
1284            topo.nudge_vertex(v, **transform_args)
1285    if apply_to_tiles:
1286      for t in topo.tiles:
1287        t.set_corners_from_edges()
1288    topo.tileable.tiles.geometry = gpd.GeoSeries(
1289      [topo.tiles[i].shape for i in range(topo.n_tiles)])
1290    topo.tileable._setup_regularised_prototile()
1291    return topo
1292
1293
1294  def get_kwargs(
1295      self,
1296      fn:Callable,
1297      **kwargs:str|float,
1298    ) -> dict:
1299    """Filter the supplied kwargs to only contain arguments required by fn.
1300
1301    Args:
1302      fn (Callable): the function that is to be inspected.
1303      **kwargs (str|float): kwargs to be filtered.
1304
1305    Returns:
1306      str|float: filtered dictionary of kwargs.
1307
1308    """
1309    args = inspect.signature(fn).parameters
1310    return {k: kwargs.pop(k) for k in dict(kwargs) if k in args}
1311
1312
1313  def zigzag_edge(
1314      self,
1315      edge:Edge,
1316      start:str = "A",
1317      n:int = 2,
1318      h:float = 0.5,
1319      smoothness:int = 0,
1320    ) -> None:
1321    """Apply zigzag transformation to supplied Edge.
1322
1323    Currently this will only work correctly if h is even.
1324
1325    TODO: make it possible for odd numbers of 'peaks' to work (this may require
1326    allowing bidirectional Edges, i.e. storing Edges in both directions so that
1327    all Tile edges are drawn CW). The `start` parameter is a temporary hack for
1328    this.
1329
1330    Args:
1331      edge (Edge): Edge to transform
1332      start (str, optional): label at one end of edge which is used to determine
1333        the sense of h, enabling C-curves with an odd number n of zigs and zags
1334        to be applied. Defaults to 'A'.
1335      n (int, optional): number of zigs and zags in the edge. Defaults to 2.
1336      h (float, optional): width of the zig zags relative to edge length.
1337        Defaults to 0.5.
1338      smoothness (int, optional): spline smoothness. 0 gives a zig zag proper,
1339        higher values will produce a sinusoid. Defaults to 0.
1340
1341    """
1342    v0, v1 = edge.vertices[0], edge.vertices[1]
1343    if n % 2 == 1 and v0.label != start:
1344      h = -h
1345    ls = self.zigzag_between_points(v0.point, v1.point, n, h, smoothness)
1346    # remove current corners
1347    self.points = {k: v for k, v in self.points.items()
1348                   if k not in edge.get_corner_IDs()[1:-1]}
1349    # add the new ones
1350    new_corners = [self.add_vertex(geom.Point(xy)) for xy in ls.coords]
1351    edge.corners = edge.vertices[:1] + new_corners + edge.vertices[-1:]
1352    if edge.right_tile is not None:
1353      edge.right_tile.set_corners_from_edges(False)
1354    if edge.left_tile is not None:
1355      edge.left_tile.set_corners_from_edges(False)
1356
1357
1358  def zigzag_between_points(
1359      self,
1360      p0:geom.Point,
1361      p1:geom.Point,
1362      n:int,
1363      h:float = 1.0,
1364      smoothness:int = 0,
1365    ) -> geom.LineString:
1366    """Return a zig zag line optionally smoothed as a spline between two points.
1367
1368    Args:
1369      p0 (geom.Point): start point.
1370      p1 (geom.Point): end point.
1371      n (int): number of zig zags.
1372      h (float, optional): amplitude of zig zags relative to distance between
1373        points. Defaults to 1.0.
1374      smoothness (int, optional): number of spline smoothed points to add. If
1375        set to 0 a straight line zig zag is produced. Defaults to 0.
1376
1377    Returns:
1378      geom.LineString: the resulting zig zag line.
1379
1380    """
1381    r = p0.distance(p1)
1382    # make a sinusoidal template
1383    x = np.linspace(0, n * np.pi, n * 2 + 1, endpoint = True)
1384    y = [np.sin(x) for x in x]
1385    spline = interpolate.InterpolatedUnivariateSpline(x, y, k = 2)
1386
1387    spline_steps = (n + smoothness) * 2 + 1
1388    xs = np.linspace(0, n * np.pi, spline_steps, endpoint = True)
1389    ys = spline(xs)
1390
1391    sfx = 1 / max(x) * r
1392    sfy = h * r / 2
1393    theta = np.arctan2(p1.y - p0.y, p1.x - p0.x)
1394
1395    ls = geom.LineString(
1396      [geom.Point(x, y) for x, y in zip(xs, ys, strict = True)])
1397    ls = affine.translate(ls, 0, -(ls.bounds[1] + ls.bounds[3]) / 2)
1398    ls = affine.scale(ls, xfact = sfx, yfact = sfy, origin = (0, 0))
1399    ls = affine.rotate(ls, theta, (0, 0), use_radians = True)
1400    x0, y0 = next(iter(ls.coords))
1401    return affine.translate(ls, p0.x - x0, p0.y - y0)
1402
1403
1404  def rotate_edge(
1405      self,
1406      edge:Edge,
1407      centre:str = "",
1408      angle:float = 0,
1409    ) -> None:
1410    """Rotate edge.
1411
1412    Args:
1413      edge (Edge): the edge to rotate.
1414      centre (str): centre of rotation which should be label of one of its
1415        end point vertices or "" when rotation will be about the Edge centroid.
1416        Defaults to "".
1417      angle (float): angle of rotation.
1418
1419    """
1420    v0, v1 = edge.vertices
1421    ls = geom.LineString([v0.point, v1.point])
1422    if v0.label == centre:
1423      c = v0.point
1424    elif v1.label == centre:
1425      c = v1.point
1426    else:
1427      c = ls.centroid
1428    ls = affine.rotate(ls, angle, origin = c)
1429    v0.point, v1.point = [geom.Point(c) for c in ls.coords]
1430
1431
1432  def scale_edge(
1433      self,
1434      edge:Edge,
1435      sf:float = 1.0,
1436    ) -> None:
1437    """Scale edge.
1438
1439    Args:
1440      edge (Edge): the edge to scale.
1441      sf (float): amount by which to scale the edge.
1442
1443    """
1444    v0, v1 = edge.vertices
1445    ls = geom.LineString([v0.point, v1.point])
1446    ls = affine.scale(ls, xfact = sf, yfact = sf, origin = ls.centroid)
1447    v0.point, v1.point = [geom.Point(c) for c in ls.coords]
1448
1449
1450  def push_vertex(
1451      self,
1452      vertex:Vertex,
1453      push_d:float,
1454    ) -> tuple[float]:
1455    """Return displacement vector to push a vertex based on incident edges.
1456
1457    Args:
1458      vertex (Vertex): the vertex to push.
1459      push_d (float): the distance to push it.
1460
1461    """
1462    neighbours = [self.points[v] for v in vertex.neighbours]
1463    dists = [vertex.point.distance(v.point) for v in neighbours]
1464    x, y = vertex.point.x, vertex.point.y
1465    unit_vectors = [((x - v.point.x) / d, (y - v.point.y) / d)
1466                    for v, d in zip(neighbours, dists, strict = True)]
1467    return  (push_d * sum([xy[0] for xy in unit_vectors]),
1468             push_d * sum([xy[1] for xy in unit_vectors]))
1469
1470
1471  def nudge_vertex(
1472      self,
1473      vertex:Vertex,
1474      dx:float,
1475      dy:float,
1476    ) -> None:
1477    """Nudge vertex by specified displacement.
1478
1479    Args:
1480      vertex (Vertex): the vertext to nudge.
1481      dx (float): x displacement.
1482      dy (float): y displacement.
1483
1484    """
1485    vertex.point = affine.translate(vertex.point, dx, dy)

Class to represent topology of a Tileable object.

NOTE: It is important that get_local_patch return the tileable elements and the translated copies in consistent sequence, i.e. if there are (say) four tiles in the unit, the local patch should be 1 2 3 4 1 2 3 4 1 2 3 4 ... and so on. This is because self.tiles[i % n_tiles] is frequently used to reference the base unit Tile which corresponds to self.tiles[i].

Topology(unit: weavingspace.tileable.Tileable, ignore_tile_ids: bool = True)
108  def __init__(
109      self,
110      unit: Tileable,
111      ignore_tile_ids:bool = True,
112    ) -> None:
113    """Class constructor.
114
115    Args:
116      unit (Tileable): the Tileable whose topology is required.
117      ignore_tile_ids (bool): (EXPERIMENTAL) if True then only consider the tile
118        shapes, not labels. If False consider any labels. Defaults to True.
119
120    """
121    # Note that the order of these setup steps is fragile sometimes
122    # not obviously so.
123    self.tileable = unit # keep this for reference
124    self.n_tiles = self.tileable.tiles.shape[0]
125    self._initialise_points_into_tiles()
126    self._setup_vertex_tile_relations()
127    self._setup_edges()
128    self._copy_base_tiles_to_patch()
129    self._assign_vertex_and_edge_base_IDs()
130    self._identify_distinct_tile_shapes(ignore_tile_ids)
131    self._find_tile_transitivity_classes(ignore_tile_ids)
132    self._find_vertex_transitivity_classes(ignore_tile_ids)
133    self._find_edge_transitivity_classes(ignore_tile_ids)
134    self.generate_dual()

Class constructor.

Args: unit (Tileable): the Tileable whose topology is required. ignore_tile_ids (bool): (EXPERIMENTAL) if True then only consider the tile shapes, not labels. If False consider any labels. Defaults to True.

the Tileable on which the topology will be based.

tiles: list[Tile]

list of the Tiles in the topology. We use polygons returned by the tileable.get_local_patch method for these. That is the base tiles and 8 adjacent copies (for a rectangular tiling), or 6 adjacent copies (for a hexagonal tiling).

points: dict[int, Vertex]

dictionary of all points (vertices and corners) in the tiling, keyed by Vertex ID.

edges: dict[tuple[int, int], Edge]

dictionary of the tiling edges, keyed by Edge ID.

unique_tile_shapes: list[shapely.geometry.polygon.Polygon]

a 'reference' tile shape one per tile shape (up to vertices, so two tiles might be the same shape, but one might have extra vertices induced by the tiling and hence is a different shape under this definition).

dual_tiles: list[shapely.geometry.polygon.Polygon]

list of geom.Polygons from which a dual tiling might be constructed.

n_tiles: int = 0

number of tiles in the base Tileable (retained for convenience).

shape_groups: list[list[int]]

list of lists of tile IDs distinguished by shape and optionally tile_id

tile_matching_transforms: list[tuple[float]]

shapely transform tuples that map tiles onto other tiles

tile_transitivity_classes: list[tuple[int]]

list of lists of tile IDs in each transitivity class

vertex_transitivity_classes: list[list[int]]

list of lists of vertex IDs in each transitivity class

edge_transitivity_classes: list[list[tuple[int]]]

list of lists of edge IDs in each transitivity class

def get_potential_symmetries( self, ignore_tile_id_labels: bool = True) -> dict[int, weavingspace.symmetry.Transform]:
553  def get_potential_symmetries(
554      self,
555      ignore_tile_id_labels:bool = True,
556    ) -> dict[int, Transform]:
557    """Assemble potential symmetries from symmetries of prototile and tiles.
558
559    Also remove any duplicates that result. The result is assigned to the
560    tile_matching_transforms attribute.
561
562    TODO: consider retaining the Symmetry objects as these carry additional
563    information that might facilitate labelling under a limited number of the
564    symmetries not all of them.
565
566    Returns:
567      dict[int, tuple[float]]: dictionary of the symmetries (transforms
568        actually) in shapely affine transform 6-tuple format.
569
570    """
571    self.tile_matching_transforms = {
572      k: Transform("translation", 0, geom.Point(0, 0), v,
573                   tiling_utils.get_translation_transform(v[0], v[1]))
574      for k, v in enumerate(self.tileable.get_vectors())}
575    if ignore_tile_id_labels:
576      n_symmetries = len(self.tile_matching_transforms)
577      ptile = self.tileable.prototile.loc[0, "geometry"]
578      for tr in ShapeMatcher(ptile).get_polygon_matches(ptile):
579        if tr.transform_type not in ["identity", "translation"]:
580          self.tile_matching_transforms[n_symmetries] = tr
581          n_symmetries = n_symmetries + 1
582      for tile in self.tiles[:self.n_tiles]:
583        for tr in ShapeMatcher(tile.shape).get_polygon_matches(tile.shape):
584          if tr.transform_type not in ["identity", "translation"]:
585            self.tile_matching_transforms[n_symmetries] = tr
586            n_symmetries = n_symmetries + 1
587      for tile in self.tiles[:self.n_tiles]:
588        sm = ShapeMatcher(tile.shape)
589        transforms = [sm.get_polygon_matches(self.tiles[i].shape)
590          for i in self.shape_groups[tile.shape_group] if i < self.n_tiles]
591        for tr in itertools.chain(*transforms):
592          if tr.transform_type not in ["identity", "translation"]:
593            self.tile_matching_transforms[n_symmetries] = tr
594            n_symmetries = n_symmetries + 1
595      self.tile_matching_transforms = self._remove_duplicate_symmetries(
596        self.tile_matching_transforms)
597    return self.tile_matching_transforms

Assemble potential symmetries from symmetries of prototile and tiles.

Also remove any duplicates that result. The result is assigned to the tile_matching_transforms attribute.

TODO: consider retaining the Symmetry objects as these carry additional information that might facilitate labelling under a limited number of the symmetries not all of them.

Returns: dict[int, tuple[float]]: dictionary of the symmetries (transforms actually) in shapely affine transform 6-tuple format.

def vertices_in_tiles( self, tiles: list[Tile]) -> list[Vertex]:
802  def vertices_in_tiles(self, tiles:list[Tile]) -> list[Vertex]:
803    """Get vertices incident on tiles in supplied list.
804
805    Args:
806      tiles (list[Tile]): tiles whose vertices are required.
807
808    Returns:
809      list[Vertex]: the required vertices.
810
811    """
812    vs = set()
813    for tile in tiles:
814      vs = vs.union(tile.get_corner_IDs())
815    return [self.points[v] for v in vs]

Get vertices incident on tiles in supplied list.

Args: tiles (list[Tile]): tiles whose vertices are required.

Returns: list[Vertex]: the required vertices.

def edges_in_tiles( self, tiles: list[Tile]) -> list[Edge]:
818  def edges_in_tiles(self, tiles:list[Tile]) -> list[Edge]:
819    """Get edges that are part of the boundary of tiles in supplied list.
820
821    Args:
822      tiles (list[Tile]): tiles whose edges are required.
823
824    Returns:
825      list[Edge]: the required edges.
826
827    """
828    es = set()
829    for tile in tiles:
830      es = es.union(tile.get_edge_IDs())
831    return [self.edges[e] for e in es]

Get edges that are part of the boundary of tiles in supplied list.

Args: tiles (list[Tile]): tiles whose edges are required.

Returns: list[Edge]: the required edges.

def generate_dual(self) -> list[shapely.geometry.polygon.Polygon]:
834  def generate_dual(self) -> list[geom.Polygon]:
835    """Create the dual tiiing for the tiling of this Topology.
836
837    TODO: make this a viable replacement for the existing dual tiling
838    generation.
839
840    TODO: also need to ensure that this finds a set of dual tiles that exhaust
841    the plane...
842
843    Returns:
844      list[geom.Polygon]: a list of polygon objects.
845
846    """
847    for v in self.points.values():
848      v.clockwise_order_incident_tiles()
849    self.dual_tiles = {}
850    base_id_sets = defaultdict(list)
851    for v in self.points.values():
852      base_id_sets[v.base_ID].append(v.ID)
853    minimal_set = [self.points[min(s)] for s in base_id_sets.values()]
854    for v in minimal_set:
855    # for v in self.points.values():
856      if v.is_interior() and len(v.tiles) > 2:
857        self.dual_tiles[v.ID] = \
858          geom.Polygon([t.centre for t in v.tiles])

Create the dual tiiing for the tiling of this Topology.

TODO: make this a viable replacement for the existing dual tiling generation.

TODO: also need to ensure that this finds a set of dual tiles that exhaust the plane...

Returns: list[geom.Polygon]: a list of polygon objects.

def get_dual_tiles(self) -> geopandas.geodataframe.GeoDataFrame:
861  def get_dual_tiles(self) -> gpd.GeoDataFrame:
862    """Return dual tiles as GeoDataFrame."""
863    n = len(self.dual_tiles)
864    return gpd.GeoDataFrame(
865      data = {"tile_id": list(self.tileable.tiles.tile_id)[:n]},
866      geometry = gpd.GeoSeries(self.dual_tiles.values()),
867      crs = self.tileable.crs)

Return dual tiles as GeoDataFrame.

def add_vertex(self, pt: shapely.geometry.point.Point) -> Vertex:
870  def add_vertex(self, pt:geom.Point) -> Vertex:
871    """Add and return Vertex at the specified point location.
872
873    No attempt is made to ensure Vertex IDs are an unbroken sequence: a new ID
874    is generated one greater than the existing highest ID. IDs will usually be
875    an unbroken sequence up to removals when geometry transformations are
876    applied.
877
878    Args:
879      pt (geom.Point): point location of the Vertex.
880
881    Returns:
882      Vertex: the added Vertex object.
883
884    """
885    n = 0 if len(self.points) == 0 else max(self.points.keys()) + 1
886    v = Vertex(pt, n)
887    self.points[n] = v
888    return v

Add and return Vertex at the specified point location.

No attempt is made to ensure Vertex IDs are an unbroken sequence: a new ID is generated one greater than the existing highest ID. IDs will usually be an unbroken sequence up to removals when geometry transformations are applied.

Args: pt (geom.Point): point location of the Vertex.

Returns: Vertex: the added Vertex object.

def add_edge( self, vs: list[Vertex]) -> Edge:
891  def add_edge(self, vs:list[Vertex]) -> Edge:
892    """Create an Edge from the suppled vertices and return it.
893
894    The new Edge is added to the edges dictionary. Edges are self indexing by
895    the IDs of their end Vertices.
896
897    Args:
898      vs (list[Vertex]): list of Vertices in the Edge to be created.
899
900    Returns:
901        Edge: the added Edge.
902
903    """
904    e = Edge(vs)
905    self.edges[e.ID] = e
906    return e

Create an Edge from the suppled vertices and return it.

The new Edge is added to the edges dictionary. Edges are self indexing by the IDs of their end Vertices.

Args: vs (list[Vertex]): list of Vertices in the Edge to be created.

Returns: Edge: the added Edge.

def polygon_matches( self, geom1: shapely.geometry.polygon.Polygon, geom2: shapely.geometry.polygon.Polygon) -> bool:
909  def polygon_matches(
910      self,
911      geom1:geom.Polygon,
912      geom2:geom.Polygon,
913    ) -> bool:
914    """Test if supplied polygons match geometrically.
915
916    Tests for equality of area, and equality of their area of overlap to their
917    shared area, i.e. Area1 == Area2 == (Area 1 intersection 2).
918
919    Args:
920      geom1 (geom.Polygon): first polygon.
921      geom2 (geom.Polygon): second polygon.
922
923    Returns:
924      bool: True if the polygons are the same, False otherwise.
925
926    """
927    a, b = geom1.area, geom2.area
928    return bool(
929      np.isclose(a, b,
930                 rtol = tiling_utils.RESOLUTION * 100,
931                 atol = tiling_utils.RESOLUTION * 100) and
932      np.isclose(a, geom1.intersection(geom2).area,
933                 rtol = tiling_utils.RESOLUTION * 100,
934                 atol = tiling_utils.RESOLUTION * 100))

Test if supplied polygons match geometrically.

Tests for equality of area, and equality of their area of overlap to their shared area, i.e. Area1 == Area2 == (Area 1 intersection 2).

Args: geom1 (geom.Polygon): first polygon. geom2 (geom.Polygon): second polygon.

Returns: bool: True if the polygons are the same, False otherwise.

def plot( self, show_original_tiles: bool = True, show_tile_centres: bool = False, show_vertex_labels: bool = True, show_vertex_ids: bool = False, show_edges: bool = True, offset_edges: bool = True, show_edge_labels: bool = False, show_dual_tiles: bool = False) -> matplotlib.axes._axes.Axes:
 996  def plot(
 997      self,
 998      show_original_tiles: bool = True,
 999      show_tile_centres: bool = False,
1000      show_vertex_labels: bool = True,
1001      show_vertex_ids: bool = False,
1002      show_edges: bool = True,
1003      offset_edges: bool = True,
1004      show_edge_labels:bool = False,
1005      show_dual_tiles: bool = False,
1006    ) -> plt.Axes:
1007    """Delegate plotting of requested elements and return plt.Axes.
1008
1009    Args:
1010      show_original_tiles (bool, optional): if True show the tiles. Defaults to
1011        True.
1012      show_tile_centres (bool, optional): if True show tile centres with upper
1013        case alphabetical labels. Defaults to False.
1014      show_vertex_labels (bool, optional): if True show tiling vertices labelled
1015        to show their equivalence classes. Defaults to True.
1016      show_vertex_ids (bool, optional): if True show vertex IDs (i.e., sequence
1017        numbers) which is useful for debugging. Defaults to False.
1018      show_edges (bool, optional): if True show tiling edges (not tile sides).
1019        Defaults to True.
1020      offset_edges (bool, optional): if True offset edges a little from and
1021        parallel to their geometric position. Defaults to True.
1022      show_edge_labels (bool, optional): if true show lower case alphabetical
1023        labels identifying edge equivalence classes. Defaults to False.
1024      show_dual_tiles (bool, optional): if True show a candidate set of dual
1025        tiles as an overlay. Defaults to False.
1026
1027    Returns:
1028      plt.Axes: a plot of the Topology as requested.
1029
1030    """
1031    fig = plt.figure(figsize = (10, 10))
1032    ax = fig.add_axes(111)
1033    extent = gpd.GeoSeries([t.shape for t in self.tiles]).total_bounds
1034    dist = max([extent[2] - extent[0], extent[3] - extent[1]]) / 100
1035    if show_original_tiles:
1036      self._plot_tiles(ax)
1037    if show_tile_centres:
1038      self._plot_tile_centres(ax)
1039    if show_vertex_labels:
1040      self._plot_vertex_labels(ax, show_vertex_ids)
1041    if show_edge_labels or show_edges:
1042      self._plot_edges(ax, show_edges, show_edge_labels, dist, offset_edges)
1043    if show_dual_tiles:
1044      self._plot_dual_tiles(ax, dist)
1045    plt.axis("off")
1046    return ax

Delegate plotting of requested elements and return plt.Axes.

Args: show_original_tiles (bool, optional): if True show the tiles. Defaults to True. show_tile_centres (bool, optional): if True show tile centres with upper case alphabetical labels. Defaults to False. show_vertex_labels (bool, optional): if True show tiling vertices labelled to show their equivalence classes. Defaults to True. show_vertex_ids (bool, optional): if True show vertex IDs (i.e., sequence numbers) which is useful for debugging. Defaults to False. show_edges (bool, optional): if True show tiling edges (not tile sides). Defaults to True. offset_edges (bool, optional): if True offset edges a little from and parallel to their geometric position. Defaults to True. show_edge_labels (bool, optional): if true show lower case alphabetical labels identifying edge equivalence classes. Defaults to False. show_dual_tiles (bool, optional): if True show a candidate set of dual tiles as an overlay. Defaults to False.

Returns: plt.Axes: a plot of the Topology as requested.

def plot_tiling_symmetries(self, **kwargs: float) -> None:
1154  def plot_tiling_symmetries(
1155      self,
1156      **kwargs:float,
1157    ) -> None:
1158    """Plot the symmetries of Topology's tiling.
1159
1160    Most of the work here is delegated to `_plot_tiling_symmetry` which is run
1161    once per symmetry on a grid of plt.Axes built by this function.
1162
1163    Args:
1164      kwargs: passed through to `_plot_tiling_symmetry`
1165
1166    """
1167    n = len(self.tile_matching_transforms)
1168    nc = int(np.ceil(np.sqrt(n)))
1169    nr = int(np.ceil(n / nc))
1170    fig = plt.figure(figsize = (12, 12 * nr / nc))
1171    for i, tr in enumerate(self.tile_matching_transforms.values()):
1172      ax = fig.add_subplot(nr, nc, i + 1)
1173      self._plot_tiling_symmetry(tr, ax, **kwargs)

Plot the symmetries of Topology's tiling.

Most of the work here is delegated to _plot_tiling_symmetry which is run once per symmetry on a grid of plt.Axes built by this function.

Args: kwargs: passed through to _plot_tiling_symmetry

def transform_geometry( self, new_topology: bool, apply_to_tiles: bool, selector: str, transform_type: str, **kwargs: float) -> Topology:
1201  def transform_geometry(
1202      self,
1203      new_topology:bool,
1204      apply_to_tiles:bool,
1205      selector:str,
1206      transform_type:str,
1207      **kwargs:float) -> Topology:
1208    r"""Get a new Topology by applying specified transformation.
1209
1210    A transformation specified by `transform_type` and keyword arguments is
1211    applied to elements in the Topology whose labels match the selector
1212    parameter. The transform is optionally applied to update tiles and
1213    optionally requests a new Topology object.
1214
1215    Implemented in this way so that transformations can be applied one at a time
1216    without creating an intermediate set of new tiles, which may be invalid and
1217    fail. So, if you wish to apply (say) 3 transforms and generate a new
1218    Topology leaving the existing one intact:
1219
1220        new_topo = old_topo.transform_geometry(True,  False, "a", ...) \
1221                           .transform_geometry(False, False, "B", ...) \
1222                           .transform_geometry(False, True,  "C", ...)
1223
1224    The first transform requests a new Topology, subsequent steps do not, and it
1225    is only the last step which attempts to create the new tile polygons.
1226
1227    **kwargs supply named parameters for the requested transformation.
1228
1229    Args:
1230      new_topology (bool): if True returns a new Topology object, else returns
1231        the current Topology modified.
1232      apply_to_tiles (bool): if True attempts to create new Tiles after the
1233        transformation has been applied. Usually set to False, unless the last
1234        transformation in a pipeline, to avoid problems of topologically invalid
1235        tiles at intermediate steps.
1236      selector (str): label of elements to which to apply the transformation.
1237        Note that all letters in the supplied string are checked, so you can
1238        use e.g. "abc" to apply a transformation to edges labelled "a", "b" or
1239        "c", or "AB" for vertices labelled "A" or "B".
1240      transform_type (str): name of the type of transformation requested.
1241        Currently supported are `zigzag_edge`, `rotate_edge`, `scale_edge`,
1242        `push_vertex`, and `nudge_vertex`. Keyword arguments for each are
1243        documented in the corresponding methods.
1244      kwargs: contains any needed arguments of the requested transform_type.
1245
1246    Returns:
1247      Topology: if new_topology is True a new Topology based on this one with
1248        after transformation, if False this Topology is returned after the
1249        transformation.
1250
1251    """
1252    print("CAUTION: new Topology will probably not be correctly labelled. "
1253          "To build a correct Topology, extract the tileable attribute and "
1254          "rebuild Topology from that.")
1255    # pickle seems to be more reliable than copy.deepcopy here
1256    topo = (pickle.loads(pickle.dumps(self))   # noqa: S301
1257            if new_topology
1258            else self)
1259    transform_args = topo.get_kwargs(getattr(topo, transform_type), **kwargs)
1260    match transform_type:
1261      case "zigzag_edge":
1262        for e in topo.edges.values():
1263          if e.label in selector:
1264            topo.zigzag_edge(e, **transform_args)
1265      case "rotate_edge":
1266        for e in topo.edges.values():
1267          if e.label in selector:
1268            topo.rotate_edge(e, **transform_args)
1269      case "scale_edge":
1270        for e in topo.edges.values():
1271          if e.label in selector:
1272            topo.scale_edge(e, **transform_args)
1273      case "push_vertex":
1274        pushes = {}
1275        for v in topo.vertices_in_tiles(topo.tiles[:topo.n_tiles]):
1276          if v.label in selector:
1277            pushes[v.base_ID] = topo.push_vertex(v, **transform_args)
1278        for base_ID, (dx, dy) in pushes.items():
1279          for v in [v for v in topo.points.values() if v.base_ID == base_ID]:
1280            v.point = affine.translate(v.point, dx, dy)
1281      case "nudge_vertex":
1282         for v in topo.points.values():
1283          if v.label in selector:
1284            topo.nudge_vertex(v, **transform_args)
1285    if apply_to_tiles:
1286      for t in topo.tiles:
1287        t.set_corners_from_edges()
1288    topo.tileable.tiles.geometry = gpd.GeoSeries(
1289      [topo.tiles[i].shape for i in range(topo.n_tiles)])
1290    topo.tileable._setup_regularised_prototile()
1291    return topo

Get a new Topology by applying specified transformation.

A transformation specified by transform_type and keyword arguments is applied to elements in the Topology whose labels match the selector parameter. The transform is optionally applied to update tiles and optionally requests a new Topology object.

Implemented in this way so that transformations can be applied one at a time without creating an intermediate set of new tiles, which may be invalid and fail. So, if you wish to apply (say) 3 transforms and generate a new Topology leaving the existing one intact:

new_topo = old_topo.transform_geometry(True,  False, "a", ...) \
                   .transform_geometry(False, False, "B", ...) \
                   .transform_geometry(False, True,  "C", ...)

The first transform requests a new Topology, subsequent steps do not, and it is only the last step which attempts to create the new tile polygons.

**kwargs supply named parameters for the requested transformation.

Args: new_topology (bool): if True returns a new Topology object, else returns the current Topology modified. apply_to_tiles (bool): if True attempts to create new Tiles after the transformation has been applied. Usually set to False, unless the last transformation in a pipeline, to avoid problems of topologically invalid tiles at intermediate steps. selector (str): label of elements to which to apply the transformation. Note that all letters in the supplied string are checked, so you can use e.g. "abc" to apply a transformation to edges labelled "a", "b" or "c", or "AB" for vertices labelled "A" or "B". transform_type (str): name of the type of transformation requested. Currently supported are zigzag_edge, rotate_edge, scale_edge, push_vertex, and nudge_vertex. Keyword arguments for each are documented in the corresponding methods. kwargs: contains any needed arguments of the requested transform_type.

Returns: Topology: if new_topology is True a new Topology based on this one with after transformation, if False this Topology is returned after the transformation.

def get_kwargs(self, fn: Callable, **kwargs: str | float) -> dict:
1294  def get_kwargs(
1295      self,
1296      fn:Callable,
1297      **kwargs:str|float,
1298    ) -> dict:
1299    """Filter the supplied kwargs to only contain arguments required by fn.
1300
1301    Args:
1302      fn (Callable): the function that is to be inspected.
1303      **kwargs (str|float): kwargs to be filtered.
1304
1305    Returns:
1306      str|float: filtered dictionary of kwargs.
1307
1308    """
1309    args = inspect.signature(fn).parameters
1310    return {k: kwargs.pop(k) for k in dict(kwargs) if k in args}

Filter the supplied kwargs to only contain arguments required by fn.

Args: fn (Callable): the function that is to be inspected. **kwargs (str|float): kwargs to be filtered.

Returns: str|float: filtered dictionary of kwargs.

def zigzag_edge( self, edge: Edge, start: str = 'A', n: int = 2, h: float = 0.5, smoothness: int = 0) -> None:
1313  def zigzag_edge(
1314      self,
1315      edge:Edge,
1316      start:str = "A",
1317      n:int = 2,
1318      h:float = 0.5,
1319      smoothness:int = 0,
1320    ) -> None:
1321    """Apply zigzag transformation to supplied Edge.
1322
1323    Currently this will only work correctly if h is even.
1324
1325    TODO: make it possible for odd numbers of 'peaks' to work (this may require
1326    allowing bidirectional Edges, i.e. storing Edges in both directions so that
1327    all Tile edges are drawn CW). The `start` parameter is a temporary hack for
1328    this.
1329
1330    Args:
1331      edge (Edge): Edge to transform
1332      start (str, optional): label at one end of edge which is used to determine
1333        the sense of h, enabling C-curves with an odd number n of zigs and zags
1334        to be applied. Defaults to 'A'.
1335      n (int, optional): number of zigs and zags in the edge. Defaults to 2.
1336      h (float, optional): width of the zig zags relative to edge length.
1337        Defaults to 0.5.
1338      smoothness (int, optional): spline smoothness. 0 gives a zig zag proper,
1339        higher values will produce a sinusoid. Defaults to 0.
1340
1341    """
1342    v0, v1 = edge.vertices[0], edge.vertices[1]
1343    if n % 2 == 1 and v0.label != start:
1344      h = -h
1345    ls = self.zigzag_between_points(v0.point, v1.point, n, h, smoothness)
1346    # remove current corners
1347    self.points = {k: v for k, v in self.points.items()
1348                   if k not in edge.get_corner_IDs()[1:-1]}
1349    # add the new ones
1350    new_corners = [self.add_vertex(geom.Point(xy)) for xy in ls.coords]
1351    edge.corners = edge.vertices[:1] + new_corners + edge.vertices[-1:]
1352    if edge.right_tile is not None:
1353      edge.right_tile.set_corners_from_edges(False)
1354    if edge.left_tile is not None:
1355      edge.left_tile.set_corners_from_edges(False)

Apply zigzag transformation to supplied Edge.

Currently this will only work correctly if h is even.

TODO: make it possible for odd numbers of 'peaks' to work (this may require allowing bidirectional Edges, i.e. storing Edges in both directions so that all Tile edges are drawn CW). The start parameter is a temporary hack for this.

Args: edge (Edge): Edge to transform start (str, optional): label at one end of edge which is used to determine the sense of h, enabling C-curves with an odd number n of zigs and zags to be applied. Defaults to 'A'. n (int, optional): number of zigs and zags in the edge. Defaults to 2. h (float, optional): width of the zig zags relative to edge length. Defaults to 0.5. smoothness (int, optional): spline smoothness. 0 gives a zig zag proper, higher values will produce a sinusoid. Defaults to 0.

def zigzag_between_points( self, p0: shapely.geometry.point.Point, p1: shapely.geometry.point.Point, n: int, h: float = 1.0, smoothness: int = 0) -> shapely.geometry.linestring.LineString:
1358  def zigzag_between_points(
1359      self,
1360      p0:geom.Point,
1361      p1:geom.Point,
1362      n:int,
1363      h:float = 1.0,
1364      smoothness:int = 0,
1365    ) -> geom.LineString:
1366    """Return a zig zag line optionally smoothed as a spline between two points.
1367
1368    Args:
1369      p0 (geom.Point): start point.
1370      p1 (geom.Point): end point.
1371      n (int): number of zig zags.
1372      h (float, optional): amplitude of zig zags relative to distance between
1373        points. Defaults to 1.0.
1374      smoothness (int, optional): number of spline smoothed points to add. If
1375        set to 0 a straight line zig zag is produced. Defaults to 0.
1376
1377    Returns:
1378      geom.LineString: the resulting zig zag line.
1379
1380    """
1381    r = p0.distance(p1)
1382    # make a sinusoidal template
1383    x = np.linspace(0, n * np.pi, n * 2 + 1, endpoint = True)
1384    y = [np.sin(x) for x in x]
1385    spline = interpolate.InterpolatedUnivariateSpline(x, y, k = 2)
1386
1387    spline_steps = (n + smoothness) * 2 + 1
1388    xs = np.linspace(0, n * np.pi, spline_steps, endpoint = True)
1389    ys = spline(xs)
1390
1391    sfx = 1 / max(x) * r
1392    sfy = h * r / 2
1393    theta = np.arctan2(p1.y - p0.y, p1.x - p0.x)
1394
1395    ls = geom.LineString(
1396      [geom.Point(x, y) for x, y in zip(xs, ys, strict = True)])
1397    ls = affine.translate(ls, 0, -(ls.bounds[1] + ls.bounds[3]) / 2)
1398    ls = affine.scale(ls, xfact = sfx, yfact = sfy, origin = (0, 0))
1399    ls = affine.rotate(ls, theta, (0, 0), use_radians = True)
1400    x0, y0 = next(iter(ls.coords))
1401    return affine.translate(ls, p0.x - x0, p0.y - y0)

Return a zig zag line optionally smoothed as a spline between two points.

Args: p0 (geom.Point): start point. p1 (geom.Point): end point. n (int): number of zig zags. h (float, optional): amplitude of zig zags relative to distance between points. Defaults to 1.0. smoothness (int, optional): number of spline smoothed points to add. If set to 0 a straight line zig zag is produced. Defaults to 0.

Returns: geom.LineString: the resulting zig zag line.

def rotate_edge( self, edge: Edge, centre: str = '', angle: float = 0) -> None:
1404  def rotate_edge(
1405      self,
1406      edge:Edge,
1407      centre:str = "",
1408      angle:float = 0,
1409    ) -> None:
1410    """Rotate edge.
1411
1412    Args:
1413      edge (Edge): the edge to rotate.
1414      centre (str): centre of rotation which should be label of one of its
1415        end point vertices or "" when rotation will be about the Edge centroid.
1416        Defaults to "".
1417      angle (float): angle of rotation.
1418
1419    """
1420    v0, v1 = edge.vertices
1421    ls = geom.LineString([v0.point, v1.point])
1422    if v0.label == centre:
1423      c = v0.point
1424    elif v1.label == centre:
1425      c = v1.point
1426    else:
1427      c = ls.centroid
1428    ls = affine.rotate(ls, angle, origin = c)
1429    v0.point, v1.point = [geom.Point(c) for c in ls.coords]

Rotate edge.

Args: edge (Edge): the edge to rotate. centre (str): centre of rotation which should be label of one of its end point vertices or "" when rotation will be about the Edge centroid. Defaults to "". angle (float): angle of rotation.

def scale_edge(self, edge: Edge, sf: float = 1.0) -> None:
1432  def scale_edge(
1433      self,
1434      edge:Edge,
1435      sf:float = 1.0,
1436    ) -> None:
1437    """Scale edge.
1438
1439    Args:
1440      edge (Edge): the edge to scale.
1441      sf (float): amount by which to scale the edge.
1442
1443    """
1444    v0, v1 = edge.vertices
1445    ls = geom.LineString([v0.point, v1.point])
1446    ls = affine.scale(ls, xfact = sf, yfact = sf, origin = ls.centroid)
1447    v0.point, v1.point = [geom.Point(c) for c in ls.coords]

Scale edge.

Args: edge (Edge): the edge to scale. sf (float): amount by which to scale the edge.

def push_vertex( self, vertex: Vertex, push_d: float) -> tuple[float]:
1450  def push_vertex(
1451      self,
1452      vertex:Vertex,
1453      push_d:float,
1454    ) -> tuple[float]:
1455    """Return displacement vector to push a vertex based on incident edges.
1456
1457    Args:
1458      vertex (Vertex): the vertex to push.
1459      push_d (float): the distance to push it.
1460
1461    """
1462    neighbours = [self.points[v] for v in vertex.neighbours]
1463    dists = [vertex.point.distance(v.point) for v in neighbours]
1464    x, y = vertex.point.x, vertex.point.y
1465    unit_vectors = [((x - v.point.x) / d, (y - v.point.y) / d)
1466                    for v, d in zip(neighbours, dists, strict = True)]
1467    return  (push_d * sum([xy[0] for xy in unit_vectors]),
1468             push_d * sum([xy[1] for xy in unit_vectors]))

Return displacement vector to push a vertex based on incident edges.

Args: vertex (Vertex): the vertex to push. push_d (float): the distance to push it.

def nudge_vertex(self, vertex: Vertex, dx: float, dy: float) -> None:
1471  def nudge_vertex(
1472      self,
1473      vertex:Vertex,
1474      dx:float,
1475      dy:float,
1476    ) -> None:
1477    """Nudge vertex by specified displacement.
1478
1479    Args:
1480      vertex (Vertex): the vertext to nudge.
1481      dx (float): x displacement.
1482      dy (float): y displacement.
1483
1484    """
1485    vertex.point = affine.translate(vertex.point, dx, dy)

Nudge vertex by specified displacement.

Args: vertex (Vertex): the vertext to nudge. dx (float): x displacement. dy (float): y displacement.

class Tile:
1488class Tile:
1489  """Class to represent essential features of polygons in a tiling."""
1490
1491  ID: int
1492  """integer ID number which indexes the Tile in the containing Topology tiles
1493  list."""
1494  base_ID: int
1495  """ID of corresponding Tile in the base tileable unit"""
1496  corners: list[Vertex]
1497  """list of Vertex objects. This includes all corners of the original polygon
1498  and any tiling vertices induced by (for example) a the corner of an adjacent
1499  tile lying halfway along an edge of the original polygon on which this tile
1500  is based. Vertex objects are stored in strictly clockwise sequence."""
1501  edges: list[Edge]
1502  """list of Edge objects that together compose the tile boundary."""
1503  edges_CW: list[bool]
1504  """list of Edge direction. Edges are stored only once in a Topology so some
1505  edges are in clockwise order and others  are in counter-clockwise order.
1506  These boolean flags are True if the corresponding Edge is clockwise, False if
1507  counter-clockwise."""
1508  label: str
1509  """tile_id label from the tileable source"""
1510  vertex_labels: list[str]
1511  """list of (upper case) letter labels of the tile corners (i.e. all corners,
1512  not only tiling vertices)."""
1513  edge_labels: list[str]
1514  """list of (lower case) letter labels of the tile edges (tiling edges, not
1515  tile sides)."""
1516  shape: geom.Polygon = None
1517  """the tile geometry (which may include some redundant points along sides
1518  where neighbouring tiles induce a tiling vertex). So for example a rectangle
1519  might have additional points along its sides:
1520
1521        +---+-------+
1522        |   |   2   |
1523        | 1 A---B---E---+
1524        |   |   |   4   |
1525        +---C 3 D-------+
1526            |   |
1527            +---+
1528
1529  In the above Tile 1 has additional point A, 2 has B and 3 has C and D induced
1530  by the corners of neighbouring tiles."""
1531  centre: geom.Point = None
1532  """a point centre for the Tile (determined by weavingspace.tiling_utils.
1533  incentre)."""
1534  shape_group: int = None
1535  """the tile shape group of this tile in its containing Topology."""
1536  transitivity_class: int = None
1537  """the tile transitivity class of this tile its containing Topology"""
1538
1539  def __init__(
1540      self,
1541      ID:int,
1542    ) -> None:
1543    """Class constructor.
1544
1545    Args:
1546      ID (int): sequence number ID of this vertex in the Topology.
1547
1548    """
1549    self.ID = ID
1550    self.corners = []
1551    self.edges = []
1552    self.edges_CW = []
1553    self.vertex_labels = []
1554    self.edge_labels = []
1555
1556
1557  def __str__(self) -> str:
1558    """Return string representation of the Tile.
1559
1560    Returns:
1561      str: string including Tile ID, list of corner vertex IDs and list of
1562        edge IDs.
1563
1564    """
1565    return (f"Tile {self.ID} Corners: {self.get_corner_IDs()} "
1566            f"Edges: {self.get_edge_IDs()}")
1567
1568
1569  def __repr__(self) -> str:
1570    return str(self)
1571
1572
1573  def get_corner_IDs(self) -> list[int]:
1574    """Return list of corner IDs (not Vertex objects).
1575
1576    Returns:
1577      list[int]: list of integer IDs of tile corners.
1578
1579    """
1580    return [c.ID for c in self.corners]
1581
1582
1583  def get_edge_IDs(self) -> list[tuple[int,int]]:
1584    """Return list of edge IDs (not Edge objects).
1585
1586    Returns:
1587      list[tuple[int]]: list of 2-element tuples of the start and end Vertex IDs
1588        of each edge.
1589
1590    """
1591    return [e.ID for e in self.edges]
1592
1593
1594  def set_shape_from_corners(self) -> None:
1595    """Set the shape attribute based on corners, and associated tile centre."""
1596    self.shape = geom.Polygon([c.point for c in self.corners])
1597    self.centre = tiling_utils.get_incentre(
1598      tiling_utils.get_clean_polygon(self.shape))
1599
1600
1601  def set_corners_from_edges(
1602      self,
1603      update_shape:bool = True,
1604    ) -> None:
1605    """Set corners attribute from the edges attribute.
1606
1607    Typically called after modification of topology edges. Optionally the shape
1608    attribute is NOT updated, which may save time when multiple changes to the
1609    edges of a tile are in process (i.e., only update the shape after all
1610    changes are complete).
1611
1612    Args:
1613      update_shape (bool, optional): if True the shape attribute will be
1614        updated, otherwise not. Defaults to True.
1615
1616    """
1617    self.corners = []
1618    for e, cw in zip(self.edges, self.edges_CW, strict = True):
1619      if cw: # clockwise to extend by all but the first corner
1620        self.corners.extend(e.corners[:-1])
1621      else: # counter-clockwise so extend in reverse
1622        self.corners.extend(e.corners[1:][::-1])
1623    if update_shape:
1624      self.set_shape_from_corners()
1625
1626
1627  def set_edge_directions(self) -> None:
1628    """Set up edges_CW attribute by inspection of the edges list.
1629
1630    It is (frankly!) hard to keep track of the correct sequence of CW/CCW order
1631    of edges as new ones are created or old ones merged. This method inspects
1632    the 'tail-head' relations between consecutive edges to set these flags
1633    correctly.
1634
1635    The test is simply to check if the 'tail' Vertex ID in each edge appears
1636    in the ID tuple of the following edge, i.e. if successive edge
1637    IDs are (0, 1) (2, 1) or (0, 1) (1, 2), then edge (0, 1) is in clockwise
1638    direction, but if we have (0, 1) (2, 3) then it is not.
1639    """
1640    edge_IDs = self.get_edge_IDs()
1641    self.edges_CW = [e1[-1] in e2 for e1, e2 in
1642                     zip(edge_IDs, edge_IDs[1:] + edge_IDs[:1], strict = True)]
1643
1644
1645  def insert_vertex_at(
1646      self,
1647      v:Vertex,
1648      i:int,
1649      update_shape:bool = False,
1650    ) -> tuple[tuple[int,int],tuple[tuple[int,int],...]]:
1651    """Insert the Vertex into tile at index position i.
1652
1653    Both corners and edges attributes are updated, and the old edge IDs for
1654    removal and the new edge itself are returned to the calling context (the
1655    containing Topology) for update of its edges collection. Optionally update
1656    the shape attribute.
1657
1658    This is NOT a generic vertex insertion method: it is only for use during
1659    Topology initialisation, and does not guarantee correct maintenance of
1660    all tile, edge and vertex relations in the general case---at any rate it
1661    has not been tested for this!
1662
1663    Args:
1664      v (Vertex): the Vertex to insert.
1665      i (int): index position in current corners after which to insert
1666        supplied Vertex.
1667      update_shape (bool, optional): if True shape attribute is updated.
1668        Defaults to False.
1669
1670    Returns:
1671      tuple: the (tuple) ID of the old edge which should be deleted, and
1672        the new Edges arising from insertion of this Vertex.
1673
1674    """
1675    self.corners = self.corners[:i] + [v] + self.corners[i:]
1676    old_edge = self.edges[i - 1]
1677    # store current ID of the affected edge for return to calling context
1678    old_edge_ID = old_edge.ID
1679    new_edges = old_edge.insert_vertex(v, self.corners[i - 1])
1680    self.edges = self.edges[:(i-1)] + new_edges + self.edges[i:]
1681    self.set_edge_directions()
1682    if update_shape:
1683      self.set_shape_from_corners()
1684    return old_edge_ID, new_edges
1685
1686
1687  def merge_edges_at_vertex(
1688      self,
1689      v:Vertex,
1690    ) -> tuple:
1691    """Merge edges that meet at the supplied Vertex.
1692
1693    It is assumed that only two tiles are impacted this one, and its neighbour
1694    across the Edge on which v lies. Both are updated. For this reason the work
1695    is delegated to `get_updated_edges_from_merge` which is run on both affected
1696    tiles, but only determines the edges to remove and the new edge to be added
1697    once. See that method for details.
1698
1699    Args:
1700      v (Vertex): Vertex at which to merge Edges. This should currently be an
1701        end
1702
1703    Returns:
1704      tuple: 2 item list of the edge IDs to be removed and a new Edge object to
1705        be added by the calling context (i.e. the containing Topology).
1706
1707    """
1708    to_remove, new_edge = self.get_updated_edges_from_merge(v)
1709    if len(v.tiles) > 1:
1710      v.tiles[1].get_updated_edges_from_merge(v, new_edge)
1711    return to_remove, new_edge
1712
1713
1714  def get_updated_edges_from_merge(
1715      self,
1716      v:Vertex,
1717      new_edge:Edge = None,
1718    ) -> None|tuple[tuple[tuple[int,int], tuple[int,int]], Edge]:
1719    """Update edges and edges_CW attributes based on insertion of Vertex.
1720
1721    If new_edge is supplied then the neighbour tile at v has already created
1722    the needed new Edge and this Edge is the one that will be 'slotted in' at
1723    the appropriate spot in the edges list.
1724
1725    The edges_CW is also updated to maintain correct directions of the edges.
1726    The corners attribute is unaffected by these changes.
1727
1728    Args:
1729      v (Vertex): existing Vertex at which to carry out the merge.
1730      new_edge (Edge, optional): if another Tile has already carried out this
1731        merge this should be the resulting new Edge for insertion into this
1732        Tile. Defaults to None (when the new Edge will be constructed).
1733
1734    Returns:
1735      None|tuple: either None (if a new edge was supplied) or a tuple
1736        of the two edge IDs to be removed and the new edge added for return to
1737        the calling context (i.e. the containing Topology).
1738
1739    """
1740    # get the two edge list index positions in which Vertex v is found
1741    i, j = self.get_edge_IDs_including_vertex(v)
1742    if new_edge is None: # then we must make a new one
1743      # also record existing edge IDs to be removed
1744      to_remove = [self.edges[i].ID, self.edges[j].ID]
1745      new_edge = self.get_merged_edge(i, j)
1746      return_edge_updates = True
1747    else:
1748      return_edge_updates = False
1749    if abs(i - j) != 1:
1750      # edge indices 'wrap' around from end of edge list to start so drop
1751      # first and last current edges and stick new one on at the end
1752      self.edges = self.edges[1:-1] + [new_edge]
1753    else:
1754      # insert new edge into list in place of the two old ones
1755      self.edges = self.edges[:i] + [new_edge] + self.edges[j+1:]
1756    # update the edge directions
1757    self.set_edge_directions()
1758    if return_edge_updates:
1759      return to_remove, new_edge
1760    return None
1761
1762
1763  def get_edge_IDs_including_vertex(
1764      self,
1765      v:Vertex,
1766    ) -> tuple[int]:
1767    """Get the (two) index positions of the edges that include supplied Vertex.
1768
1769    Args:
1770        v (Vertex): Vertex of interest.
1771
1772    Returns:
1773      tuple[int]: two index positions of Edges in edges list that contain v.
1774
1775    """
1776    return (i for i, e in enumerate(self.edges) if v.ID in e.ID)
1777
1778
1779  def get_merged_edge(self, i:int, j:int) -> Edge:
1780    """Return edge made by merging existing edges at i and j in the edges list.
1781
1782    For example, if the current list of edge IDs was
1783
1784        (0 1 2) (4 2) (4 5) (5 0)
1785
1786    and the merge requested is 0 and 1, the resulting new edge is constructed
1787    from vertices (0 1 2 4).
1788
1789    Returns:
1790      Edge: the requested new Edge.
1791
1792    """
1793    # if i and j are not consecutive, then j is predecessor edge
1794    if abs(i - j) != 1:
1795      i, j = j, i
1796    # get edges and their directions
1797    ei, ej = self.edges[i], self.edges[j]
1798    CWi, CWj = self.edges_CW[i], self.edges_CW[j]
1799    # DON'T MESS WITH THIS!!!
1800    # for predecessors (the head) we want everything including the Vertex
1801    # where the merge is occurring; for successors (the tail) we want all but
1802    # the first Vertex (which is the one where the merge is occurring). In both
1803    # cases contingent on whether existing Edges are CW or CCW we may need to
1804    # flip the Vertex sequence to ensure that the merge Vertex is in the middle
1805    # of the new edge that will be created
1806    head = ei.corners if CWi else ei.corners[::-1]
1807    tail = ej.corners[1:] if CWj else ej.corners[::-1][1:]
1808    v_sequence = (head if CWi else head[::-1]) + (tail if CWj else tail[::-1])
1809    return Edge(v_sequence)
1810
1811
1812  def offset_corners(
1813      self,
1814      offset:int,
1815    ) -> None:
1816    """Shift shape, corners, edges, and edges_CW by an offset amount.
1817
1818    This is used to align tiles that are similar, which is required for correct
1819    transfer of 'base' tile labelling on to 'radius 1' tiles during Topology
1820    construction.
1821
1822    Args:
1823      offset (int): the number of positions to shift the lists.
1824
1825    """
1826    if offset is not None or offset != 0:
1827      self.corners = self.corners[offset:] + self.corners[:offset]
1828      self.shape = geom.Polygon([c.point for c in self.corners])
1829      self.edges = self.edges[offset:] + self.edges[:offset]
1830      self.edges_CW = self.edges_CW[offset:] + self.edges_CW[:offset]
1831
1832
1833  def get_edge_label(self, e:Edge) -> str:
1834    """Return edge label of specified edge.
1835
1836    Args:
1837        e (Edge): Edge whose label is required.
1838
1839    Returns:
1840        str: requested edge label.
1841
1842    """
1843    return self.edge_labels[self.get_edge_IDs().index(e.ID)]
1844
1845
1846  def get_corner_label(self, v:Vertex) -> str:
1847    """Return corner label of specified corner.
1848
1849    Args:
1850        v (Vertex): corner whose label is required.
1851
1852    Returns:
1853        str: requested corner label.
1854
1855    """
1856    return self.edge_labels[self.get_corner_IDs().index(v.ID)]
1857
1858
1859  def angle_at(self, v:Vertex) -> float:
1860    """Return interior angle at the specified corner (in degrees).
1861
1862    Args:
1863        v (Vertex): corner where angle is requested.
1864
1865    Returns:
1866        float: angle at corner v in degrees.
1867
1868    """
1869    i = self.corners.index(v)
1870    n = len(self.corners)
1871    return tiling_utils.get_inner_angle(self.corners[i-1].point,
1872                                        self.corners[i].point,
1873                                        self.corners[(i + 1) % n].point)

Class to represent essential features of polygons in a tiling.

Tile(ID: int)
1539  def __init__(
1540      self,
1541      ID:int,
1542    ) -> None:
1543    """Class constructor.
1544
1545    Args:
1546      ID (int): sequence number ID of this vertex in the Topology.
1547
1548    """
1549    self.ID = ID
1550    self.corners = []
1551    self.edges = []
1552    self.edges_CW = []
1553    self.vertex_labels = []
1554    self.edge_labels = []

Class constructor.

Args: ID (int): sequence number ID of this vertex in the Topology.

ID: int

integer ID number which indexes the Tile in the containing Topology tiles list.

base_ID: int

ID of corresponding Tile in the base tileable unit

corners: list[Vertex]

list of Vertex objects. This includes all corners of the original polygon and any tiling vertices induced by (for example) a the corner of an adjacent tile lying halfway along an edge of the original polygon on which this tile is based. Vertex objects are stored in strictly clockwise sequence.

edges: list[Edge]

list of Edge objects that together compose the tile boundary.

edges_CW: list[bool]

list of Edge direction. Edges are stored only once in a Topology so some edges are in clockwise order and others are in counter-clockwise order. These boolean flags are True if the corresponding Edge is clockwise, False if counter-clockwise.

label: str

tile_id label from the tileable source

vertex_labels: list[str]

list of (upper case) letter labels of the tile corners (i.e. all corners, not only tiling vertices).

edge_labels: list[str]

list of (lower case) letter labels of the tile edges (tiling edges, not tile sides).

shape: shapely.geometry.polygon.Polygon = None

the tile geometry (which may include some redundant points along sides where neighbouring tiles induce a tiling vertex). So for example a rectangle might have additional points along its sides:

  +---+-------+
  |   |   2   |
  | 1 A---B---E---+
  |   |   |   4   |
  +---C 3 D-------+
      |   |
      +---+

In the above Tile 1 has additional point A, 2 has B and 3 has C and D induced by the corners of neighbouring tiles.

centre: shapely.geometry.point.Point = None

a point centre for the Tile (determined by weavingspace.tiling_utils. incentre).

shape_group: int = None

the tile shape group of this tile in its containing Topology.

transitivity_class: int = None

the tile transitivity class of this tile its containing Topology

def get_corner_IDs(self) -> list[int]:
1573  def get_corner_IDs(self) -> list[int]:
1574    """Return list of corner IDs (not Vertex objects).
1575
1576    Returns:
1577      list[int]: list of integer IDs of tile corners.
1578
1579    """
1580    return [c.ID for c in self.corners]

Return list of corner IDs (not Vertex objects).

Returns: list[int]: list of integer IDs of tile corners.

def get_edge_IDs(self) -> list[tuple[int, int]]:
1583  def get_edge_IDs(self) -> list[tuple[int,int]]:
1584    """Return list of edge IDs (not Edge objects).
1585
1586    Returns:
1587      list[tuple[int]]: list of 2-element tuples of the start and end Vertex IDs
1588        of each edge.
1589
1590    """
1591    return [e.ID for e in self.edges]

Return list of edge IDs (not Edge objects).

Returns: list[tuple[int]]: list of 2-element tuples of the start and end Vertex IDs of each edge.

def set_shape_from_corners(self) -> None:
1594  def set_shape_from_corners(self) -> None:
1595    """Set the shape attribute based on corners, and associated tile centre."""
1596    self.shape = geom.Polygon([c.point for c in self.corners])
1597    self.centre = tiling_utils.get_incentre(
1598      tiling_utils.get_clean_polygon(self.shape))

Set the shape attribute based on corners, and associated tile centre.

def set_corners_from_edges(self, update_shape: bool = True) -> None:
1601  def set_corners_from_edges(
1602      self,
1603      update_shape:bool = True,
1604    ) -> None:
1605    """Set corners attribute from the edges attribute.
1606
1607    Typically called after modification of topology edges. Optionally the shape
1608    attribute is NOT updated, which may save time when multiple changes to the
1609    edges of a tile are in process (i.e., only update the shape after all
1610    changes are complete).
1611
1612    Args:
1613      update_shape (bool, optional): if True the shape attribute will be
1614        updated, otherwise not. Defaults to True.
1615
1616    """
1617    self.corners = []
1618    for e, cw in zip(self.edges, self.edges_CW, strict = True):
1619      if cw: # clockwise to extend by all but the first corner
1620        self.corners.extend(e.corners[:-1])
1621      else: # counter-clockwise so extend in reverse
1622        self.corners.extend(e.corners[1:][::-1])
1623    if update_shape:
1624      self.set_shape_from_corners()

Set corners attribute from the edges attribute.

Typically called after modification of topology edges. Optionally the shape attribute is NOT updated, which may save time when multiple changes to the edges of a tile are in process (i.e., only update the shape after all changes are complete).

Args: update_shape (bool, optional): if True the shape attribute will be updated, otherwise not. Defaults to True.

def set_edge_directions(self) -> None:
1627  def set_edge_directions(self) -> None:
1628    """Set up edges_CW attribute by inspection of the edges list.
1629
1630    It is (frankly!) hard to keep track of the correct sequence of CW/CCW order
1631    of edges as new ones are created or old ones merged. This method inspects
1632    the 'tail-head' relations between consecutive edges to set these flags
1633    correctly.
1634
1635    The test is simply to check if the 'tail' Vertex ID in each edge appears
1636    in the ID tuple of the following edge, i.e. if successive edge
1637    IDs are (0, 1) (2, 1) or (0, 1) (1, 2), then edge (0, 1) is in clockwise
1638    direction, but if we have (0, 1) (2, 3) then it is not.
1639    """
1640    edge_IDs = self.get_edge_IDs()
1641    self.edges_CW = [e1[-1] in e2 for e1, e2 in
1642                     zip(edge_IDs, edge_IDs[1:] + edge_IDs[:1], strict = True)]

Set up edges_CW attribute by inspection of the edges list.

It is (frankly!) hard to keep track of the correct sequence of CW/CCW order of edges as new ones are created or old ones merged. This method inspects the 'tail-head' relations between consecutive edges to set these flags correctly.

The test is simply to check if the 'tail' Vertex ID in each edge appears in the ID tuple of the following edge, i.e. if successive edge IDs are (0, 1) (2, 1) or (0, 1) (1, 2), then edge (0, 1) is in clockwise direction, but if we have (0, 1) (2, 3) then it is not.

def insert_vertex_at( self, v: Vertex, i: int, update_shape: bool = False) -> tuple[tuple[int, int], tuple[tuple[int, int], ...]]:
1645  def insert_vertex_at(
1646      self,
1647      v:Vertex,
1648      i:int,
1649      update_shape:bool = False,
1650    ) -> tuple[tuple[int,int],tuple[tuple[int,int],...]]:
1651    """Insert the Vertex into tile at index position i.
1652
1653    Both corners and edges attributes are updated, and the old edge IDs for
1654    removal and the new edge itself are returned to the calling context (the
1655    containing Topology) for update of its edges collection. Optionally update
1656    the shape attribute.
1657
1658    This is NOT a generic vertex insertion method: it is only for use during
1659    Topology initialisation, and does not guarantee correct maintenance of
1660    all tile, edge and vertex relations in the general case---at any rate it
1661    has not been tested for this!
1662
1663    Args:
1664      v (Vertex): the Vertex to insert.
1665      i (int): index position in current corners after which to insert
1666        supplied Vertex.
1667      update_shape (bool, optional): if True shape attribute is updated.
1668        Defaults to False.
1669
1670    Returns:
1671      tuple: the (tuple) ID of the old edge which should be deleted, and
1672        the new Edges arising from insertion of this Vertex.
1673
1674    """
1675    self.corners = self.corners[:i] + [v] + self.corners[i:]
1676    old_edge = self.edges[i - 1]
1677    # store current ID of the affected edge for return to calling context
1678    old_edge_ID = old_edge.ID
1679    new_edges = old_edge.insert_vertex(v, self.corners[i - 1])
1680    self.edges = self.edges[:(i-1)] + new_edges + self.edges[i:]
1681    self.set_edge_directions()
1682    if update_shape:
1683      self.set_shape_from_corners()
1684    return old_edge_ID, new_edges

Insert the Vertex into tile at index position i.

Both corners and edges attributes are updated, and the old edge IDs for removal and the new edge itself are returned to the calling context (the containing Topology) for update of its edges collection. Optionally update the shape attribute.

This is NOT a generic vertex insertion method: it is only for use during Topology initialisation, and does not guarantee correct maintenance of all tile, edge and vertex relations in the general case---at any rate it has not been tested for this!

Args: v (Vertex): the Vertex to insert. i (int): index position in current corners after which to insert supplied Vertex. update_shape (bool, optional): if True shape attribute is updated. Defaults to False.

Returns: tuple: the (tuple) ID of the old edge which should be deleted, and the new Edges arising from insertion of this Vertex.

def merge_edges_at_vertex(self, v: Vertex) -> tuple:
1687  def merge_edges_at_vertex(
1688      self,
1689      v:Vertex,
1690    ) -> tuple:
1691    """Merge edges that meet at the supplied Vertex.
1692
1693    It is assumed that only two tiles are impacted this one, and its neighbour
1694    across the Edge on which v lies. Both are updated. For this reason the work
1695    is delegated to `get_updated_edges_from_merge` which is run on both affected
1696    tiles, but only determines the edges to remove and the new edge to be added
1697    once. See that method for details.
1698
1699    Args:
1700      v (Vertex): Vertex at which to merge Edges. This should currently be an
1701        end
1702
1703    Returns:
1704      tuple: 2 item list of the edge IDs to be removed and a new Edge object to
1705        be added by the calling context (i.e. the containing Topology).
1706
1707    """
1708    to_remove, new_edge = self.get_updated_edges_from_merge(v)
1709    if len(v.tiles) > 1:
1710      v.tiles[1].get_updated_edges_from_merge(v, new_edge)
1711    return to_remove, new_edge

Merge edges that meet at the supplied Vertex.

It is assumed that only two tiles are impacted this one, and its neighbour across the Edge on which v lies. Both are updated. For this reason the work is delegated to get_updated_edges_from_merge which is run on both affected tiles, but only determines the edges to remove and the new edge to be added once. See that method for details.

Args: v (Vertex): Vertex at which to merge Edges. This should currently be an end

Returns: tuple: 2 item list of the edge IDs to be removed and a new Edge object to be added by the calling context (i.e. the containing Topology).

def get_updated_edges_from_merge( self, v: Vertex, new_edge: Edge = None) -> None | tuple[tuple[tuple[int, int], tuple[int, int]], Edge]:
1714  def get_updated_edges_from_merge(
1715      self,
1716      v:Vertex,
1717      new_edge:Edge = None,
1718    ) -> None|tuple[tuple[tuple[int,int], tuple[int,int]], Edge]:
1719    """Update edges and edges_CW attributes based on insertion of Vertex.
1720
1721    If new_edge is supplied then the neighbour tile at v has already created
1722    the needed new Edge and this Edge is the one that will be 'slotted in' at
1723    the appropriate spot in the edges list.
1724
1725    The edges_CW is also updated to maintain correct directions of the edges.
1726    The corners attribute is unaffected by these changes.
1727
1728    Args:
1729      v (Vertex): existing Vertex at which to carry out the merge.
1730      new_edge (Edge, optional): if another Tile has already carried out this
1731        merge this should be the resulting new Edge for insertion into this
1732        Tile. Defaults to None (when the new Edge will be constructed).
1733
1734    Returns:
1735      None|tuple: either None (if a new edge was supplied) or a tuple
1736        of the two edge IDs to be removed and the new edge added for return to
1737        the calling context (i.e. the containing Topology).
1738
1739    """
1740    # get the two edge list index positions in which Vertex v is found
1741    i, j = self.get_edge_IDs_including_vertex(v)
1742    if new_edge is None: # then we must make a new one
1743      # also record existing edge IDs to be removed
1744      to_remove = [self.edges[i].ID, self.edges[j].ID]
1745      new_edge = self.get_merged_edge(i, j)
1746      return_edge_updates = True
1747    else:
1748      return_edge_updates = False
1749    if abs(i - j) != 1:
1750      # edge indices 'wrap' around from end of edge list to start so drop
1751      # first and last current edges and stick new one on at the end
1752      self.edges = self.edges[1:-1] + [new_edge]
1753    else:
1754      # insert new edge into list in place of the two old ones
1755      self.edges = self.edges[:i] + [new_edge] + self.edges[j+1:]
1756    # update the edge directions
1757    self.set_edge_directions()
1758    if return_edge_updates:
1759      return to_remove, new_edge
1760    return None

Update edges and edges_CW attributes based on insertion of Vertex.

If new_edge is supplied then the neighbour tile at v has already created the needed new Edge and this Edge is the one that will be 'slotted in' at the appropriate spot in the edges list.

The edges_CW is also updated to maintain correct directions of the edges. The corners attribute is unaffected by these changes.

Args: v (Vertex): existing Vertex at which to carry out the merge. new_edge (Edge, optional): if another Tile has already carried out this merge this should be the resulting new Edge for insertion into this Tile. Defaults to None (when the new Edge will be constructed).

Returns: None|tuple: either None (if a new edge was supplied) or a tuple of the two edge IDs to be removed and the new edge added for return to the calling context (i.e. the containing Topology).

def get_edge_IDs_including_vertex(self, v: Vertex) -> tuple[int]:
1763  def get_edge_IDs_including_vertex(
1764      self,
1765      v:Vertex,
1766    ) -> tuple[int]:
1767    """Get the (two) index positions of the edges that include supplied Vertex.
1768
1769    Args:
1770        v (Vertex): Vertex of interest.
1771
1772    Returns:
1773      tuple[int]: two index positions of Edges in edges list that contain v.
1774
1775    """
1776    return (i for i, e in enumerate(self.edges) if v.ID in e.ID)

Get the (two) index positions of the edges that include supplied Vertex.

Args: v (Vertex): Vertex of interest.

Returns: tuple[int]: two index positions of Edges in edges list that contain v.

def get_merged_edge(self, i: int, j: int) -> Edge:
1779  def get_merged_edge(self, i:int, j:int) -> Edge:
1780    """Return edge made by merging existing edges at i and j in the edges list.
1781
1782    For example, if the current list of edge IDs was
1783
1784        (0 1 2) (4 2) (4 5) (5 0)
1785
1786    and the merge requested is 0 and 1, the resulting new edge is constructed
1787    from vertices (0 1 2 4).
1788
1789    Returns:
1790      Edge: the requested new Edge.
1791
1792    """
1793    # if i and j are not consecutive, then j is predecessor edge
1794    if abs(i - j) != 1:
1795      i, j = j, i
1796    # get edges and their directions
1797    ei, ej = self.edges[i], self.edges[j]
1798    CWi, CWj = self.edges_CW[i], self.edges_CW[j]
1799    # DON'T MESS WITH THIS!!!
1800    # for predecessors (the head) we want everything including the Vertex
1801    # where the merge is occurring; for successors (the tail) we want all but
1802    # the first Vertex (which is the one where the merge is occurring). In both
1803    # cases contingent on whether existing Edges are CW or CCW we may need to
1804    # flip the Vertex sequence to ensure that the merge Vertex is in the middle
1805    # of the new edge that will be created
1806    head = ei.corners if CWi else ei.corners[::-1]
1807    tail = ej.corners[1:] if CWj else ej.corners[::-1][1:]
1808    v_sequence = (head if CWi else head[::-1]) + (tail if CWj else tail[::-1])
1809    return Edge(v_sequence)

Return edge made by merging existing edges at i and j in the edges list.

For example, if the current list of edge IDs was

(0 1 2) (4 2) (4 5) (5 0)

and the merge requested is 0 and 1, the resulting new edge is constructed from vertices (0 1 2 4).

Returns: Edge: the requested new Edge.

def offset_corners(self, offset: int) -> None:
1812  def offset_corners(
1813      self,
1814      offset:int,
1815    ) -> None:
1816    """Shift shape, corners, edges, and edges_CW by an offset amount.
1817
1818    This is used to align tiles that are similar, which is required for correct
1819    transfer of 'base' tile labelling on to 'radius 1' tiles during Topology
1820    construction.
1821
1822    Args:
1823      offset (int): the number of positions to shift the lists.
1824
1825    """
1826    if offset is not None or offset != 0:
1827      self.corners = self.corners[offset:] + self.corners[:offset]
1828      self.shape = geom.Polygon([c.point for c in self.corners])
1829      self.edges = self.edges[offset:] + self.edges[:offset]
1830      self.edges_CW = self.edges_CW[offset:] + self.edges_CW[:offset]

Shift shape, corners, edges, and edges_CW by an offset amount.

This is used to align tiles that are similar, which is required for correct transfer of 'base' tile labelling on to 'radius 1' tiles during Topology construction.

Args: offset (int): the number of positions to shift the lists.

def get_edge_label(self, e: Edge) -> str:
1833  def get_edge_label(self, e:Edge) -> str:
1834    """Return edge label of specified edge.
1835
1836    Args:
1837        e (Edge): Edge whose label is required.
1838
1839    Returns:
1840        str: requested edge label.
1841
1842    """
1843    return self.edge_labels[self.get_edge_IDs().index(e.ID)]

Return edge label of specified edge.

Args: e (Edge): Edge whose label is required.

Returns: str: requested edge label.

def get_corner_label(self, v: Vertex) -> str:
1846  def get_corner_label(self, v:Vertex) -> str:
1847    """Return corner label of specified corner.
1848
1849    Args:
1850        v (Vertex): corner whose label is required.
1851
1852    Returns:
1853        str: requested corner label.
1854
1855    """
1856    return self.edge_labels[self.get_corner_IDs().index(v.ID)]

Return corner label of specified corner.

Args: v (Vertex): corner whose label is required.

Returns: str: requested corner label.

def angle_at(self, v: Vertex) -> float:
1859  def angle_at(self, v:Vertex) -> float:
1860    """Return interior angle at the specified corner (in degrees).
1861
1862    Args:
1863        v (Vertex): corner where angle is requested.
1864
1865    Returns:
1866        float: angle at corner v in degrees.
1867
1868    """
1869    i = self.corners.index(v)
1870    n = len(self.corners)
1871    return tiling_utils.get_inner_angle(self.corners[i-1].point,
1872                                        self.corners[i].point,
1873                                        self.corners[(i + 1) % n].point)

Return interior angle at the specified corner (in degrees).

Args: v (Vertex): corner where angle is requested.

Returns: float: angle at corner v in degrees.

class Vertex:
1876class Vertex:
1877  """Class to store attributes of a vertex in a tiling."""
1878
1879  point: geom.Point
1880  """point (geom.Point): point location of the vertex."""
1881  ID: int
1882  """integer (mostly but not necessarily in sequence) of vertex keyed into the
1883  points dictionary of the containing Topology."""
1884  tiles: list[Tile]
1885  """list of Tiles incident on this vertex."""
1886  neighbours: list[int]
1887  """list of the immediately adjacent other corner IDs. Only required to
1888  determine if a point is a tiling vertex (when it will have) three or more
1889  neighbours, so only IDs are stored."""
1890  base_ID: int = 1_000_000
1891  """ID of corresponding Vertex in the tileable base_unit"""
1892  transitivity_class: int = None
1893  """transitivity class of the vertex under symmetries of the tiling"""
1894  label: str = ""
1895  """the (upper case letter) label of the vertex under the symmetries of the
1896  tiling."""
1897  is_tiling_vertex: bool = True
1898  """is_tiling_vertex (bool): True if this is a tiling vertex, rather than a
1899  tile corner. E.g., A below is a corner, not a tiling vertex. B is a tiling
1900  vertex:
1901
1902      +-------+
1903      | 1     |
1904      |   A---B---+
1905      |   | 2     |
1906      +---C   +---+
1907          |   |
1908          +---+"""
1909
1910
1911  def __init__(
1912      self,
1913      point:geom.Point,
1914      ID:int,
1915    ) -> None:
1916    """Class constructor.
1917
1918    Args:
1919      point (geom.Point): point location of the vertex.
1920      ID (int): a unique integer ID (which will be its key in the containing
1921        Topology points dictionary).
1922
1923    """
1924    self.point = point
1925    self.ID = ID
1926    self.base_ID = self.ID
1927    self.tiles = []
1928    self.neighbours = []
1929
1930
1931  def __str__(self) -> str:
1932    """Return string representation of Vertex.
1933
1934    Returns:
1935        str: string including ID, point and list of incident Tiles.
1936
1937    """
1938    return f"Vertex {self.ID} at {self.point} Tiles: {self.get_tile_IDs()}"
1939
1940
1941  def __repr__(self) -> str:
1942    return str(self)
1943
1944
1945  def get_tile_IDs(self) -> list[int]:
1946    """Return list of Tile IDs (not the Tiles themselves).
1947
1948    Returns:
1949        list[int]: list of Tile IDs
1950
1951    """
1952    return [t.ID for t in self.tiles]
1953
1954
1955  def add_tile(self, tile:Tile) -> None:
1956    """Add supplied Tile to the tiles list if it is not already present.
1957
1958    Args:
1959        tile (Tile): Tile to add.
1960
1961    """
1962    if tile not in self.tiles:
1963      self.tiles.append(tile)
1964
1965
1966  def add_neighbour(
1967      self,
1968      vertex_id:int,
1969    ) -> None:
1970    """Add supplied ID to the neighbours list if it is not already present.
1971
1972    Args:
1973      vertex_id (int): ID to add to the neighbours list.
1974
1975    """
1976    if vertex_id not in self.neighbours:
1977      self.neighbours.append(vertex_id)
1978
1979
1980  def clockwise_order_incident_tiles(self) -> None:
1981    """Reorder tiles list clockwise (this is for dual tiling construction)."""
1982    cw_order = self._order_of_pts_cw_around_centre(
1983      [t.centre for t in self.tiles], self.point)
1984    self.tiles = [self.tiles[i] for i in cw_order]
1985
1986
1987  def is_interior(self) -> bool:
1988    """Test if vertex is completely enclosed by its incident Tiles.
1989
1990    Based on summing the interior angles of the incident tiles at this vertex.
1991
1992    Returns:
1993        bool: True if vertex is completely enclosed by incident Tiles.
1994
1995    """
1996    return abs(360 - sum([t.angle_at(self) for t in self.tiles])) \
1997                     < tiling_utils.RESOLUTION
1998
1999
2000  def _order_of_pts_cw_around_centre(
2001      self,
2002      pts:list[geom.Point],
2003      centre:geom.Point,
2004    ) -> list[int]:
2005    """Return order of points clockwise relative to centre point.
2006
2007    Args:
2008      pts (list[geom.Point]): list of points to order.
2009      centre (geom.Point): centre relative to which CW order is determined.
2010
2011    Returns:
2012      list[int]: list of indices of reordered points.
2013
2014    """
2015    dx = [p.x - centre.x for p in pts]
2016    dy = [p.y - centre.y for p in pts]
2017    angles = [np.arctan2(dy, dx) for dx, dy in zip(dx, dy, strict = True)]
2018    d = dict(zip(angles, range(len(pts)), strict = True))
2019    return [i for angle, i in sorted(d.items(), reverse=True)]

Class to store attributes of a vertex in a tiling.

Vertex(point: shapely.geometry.point.Point, ID: int)
1911  def __init__(
1912      self,
1913      point:geom.Point,
1914      ID:int,
1915    ) -> None:
1916    """Class constructor.
1917
1918    Args:
1919      point (geom.Point): point location of the vertex.
1920      ID (int): a unique integer ID (which will be its key in the containing
1921        Topology points dictionary).
1922
1923    """
1924    self.point = point
1925    self.ID = ID
1926    self.base_ID = self.ID
1927    self.tiles = []
1928    self.neighbours = []

Class constructor.

Args: point (geom.Point): point location of the vertex. ID (int): a unique integer ID (which will be its key in the containing Topology points dictionary).

point: shapely.geometry.point.Point

point (geom.Point): point location of the vertex.

ID: int

integer (mostly but not necessarily in sequence) of vertex keyed into the points dictionary of the containing Topology.

tiles: list[Tile]

list of Tiles incident on this vertex.

neighbours: list[int]

list of the immediately adjacent other corner IDs. Only required to determine if a point is a tiling vertex (when it will have) three or more neighbours, so only IDs are stored.

base_ID: int = 1000000

ID of corresponding Vertex in the tileable base_unit

transitivity_class: int = None

transitivity class of the vertex under symmetries of the tiling

label: str = ''

the (upper case letter) label of the vertex under the symmetries of the tiling.

is_tiling_vertex: bool = True

is_tiling_vertex (bool): True if this is a tiling vertex, rather than a tile corner. E.g., A below is a corner, not a tiling vertex. B is a tiling vertex:

+-------+
| 1     |
|   A---B---+
|   | 2     |
+---C   +---+
    |   |
    +---+
def get_tile_IDs(self) -> list[int]:
1945  def get_tile_IDs(self) -> list[int]:
1946    """Return list of Tile IDs (not the Tiles themselves).
1947
1948    Returns:
1949        list[int]: list of Tile IDs
1950
1951    """
1952    return [t.ID for t in self.tiles]

Return list of Tile IDs (not the Tiles themselves).

Returns: list[int]: list of Tile IDs

def add_tile(self, tile: Tile) -> None:
1955  def add_tile(self, tile:Tile) -> None:
1956    """Add supplied Tile to the tiles list if it is not already present.
1957
1958    Args:
1959        tile (Tile): Tile to add.
1960
1961    """
1962    if tile not in self.tiles:
1963      self.tiles.append(tile)

Add supplied Tile to the tiles list if it is not already present.

Args: tile (Tile): Tile to add.

def add_neighbour(self, vertex_id: int) -> None:
1966  def add_neighbour(
1967      self,
1968      vertex_id:int,
1969    ) -> None:
1970    """Add supplied ID to the neighbours list if it is not already present.
1971
1972    Args:
1973      vertex_id (int): ID to add to the neighbours list.
1974
1975    """
1976    if vertex_id not in self.neighbours:
1977      self.neighbours.append(vertex_id)

Add supplied ID to the neighbours list if it is not already present.

Args: vertex_id (int): ID to add to the neighbours list.

def clockwise_order_incident_tiles(self) -> None:
1980  def clockwise_order_incident_tiles(self) -> None:
1981    """Reorder tiles list clockwise (this is for dual tiling construction)."""
1982    cw_order = self._order_of_pts_cw_around_centre(
1983      [t.centre for t in self.tiles], self.point)
1984    self.tiles = [self.tiles[i] for i in cw_order]

Reorder tiles list clockwise (this is for dual tiling construction).

def is_interior(self) -> bool:
1987  def is_interior(self) -> bool:
1988    """Test if vertex is completely enclosed by its incident Tiles.
1989
1990    Based on summing the interior angles of the incident tiles at this vertex.
1991
1992    Returns:
1993        bool: True if vertex is completely enclosed by incident Tiles.
1994
1995    """
1996    return abs(360 - sum([t.angle_at(self) for t in self.tiles])) \
1997                     < tiling_utils.RESOLUTION

Test if vertex is completely enclosed by its incident Tiles.

Based on summing the interior angles of the incident tiles at this vertex.

Returns: bool: True if vertex is completely enclosed by incident Tiles.

class Edge:
2022class Edge:
2023  """Class to represent edges in a tiling (not tile sides)."""
2024
2025  ID: tuple[int]
2026  """IDs of the vertices at ends of the edge. Used as key in the containing
2027  Topology's edges dictionary."""
2028  vertices: list[Vertex]
2029  """two item list of the end vertices."""
2030  corners: list[Vertex]
2031  """list of all the vertices in the edge (including its end vertices). In a
2032  'normal' edge to edge tiling corners and vertices will be identical."""
2033  right_tile: Tile = None
2034  """the tile to the right of the edge traversed from its first to its last
2035  vertex. Given clockwise winding default, all edges will have a right_tile."""
2036  left_tile: Tile = None
2037  """the tile to the left of the edge traversed from its first to its last
2038  vertex. Exterior edges of the tiles in a Topology will not have a left_tile.
2039  """
2040  base_ID: tuple[int] = (1_000_000, 1_000_000)
2041  """ID of corresponding edge in the base tileable"""
2042  transitivity_class: int = None
2043  """transitivity class of the edge under symmetries of the tiling"""
2044  label: str = ""
2045  """the (lower case letter) label of the edge under the symmetries of the
2046  tiling."""
2047
2048  def __init__(
2049      self,
2050      corners:list[Vertex],
2051    ) -> None:
2052    """Class constructor.
2053
2054    Initialises the corners and vertices lists and sets ID to (vertices[0].ID,
2055    vertices[1].ID). The vertices list is all the corners with is_tiling_vertex
2056    property True -- Note that during initialisation the default of this
2057    property is True until after the relations between tiles and vertices have
2058    been determined.
2059
2060    Args:
2061      corners (list[Vertex]): list of all corners along the edge.
2062
2063    """
2064    self.corners = corners
2065    self.vertices = [v for v in self.corners if v.is_tiling_vertex]
2066    self.ID = tuple(v.ID for v in self.vertices)
2067
2068
2069  def __str__(self) -> str:
2070    """Return a string representation of the Edge.
2071
2072    Returns:
2073      str: include ID and a list of corner vertex IDs.
2074
2075    """
2076    return f"Edge {self.ID} Corners: {[c.ID for c in self.corners]}"
2077
2078
2079  def __repr__(self) -> str:
2080    return str(self)
2081
2082
2083  def get_corner_IDs(self) -> list[int]:
2084    """Return the IDs of edge corners.
2085
2086    Returns:
2087      list[int]: IDs of all corners.
2088
2089    """
2090    return [c.ID for c in self.corners]
2091
2092
2093  def get_vertex_IDs(self) -> list[int]:
2094    """Return IDs of edge vertices.
2095
2096    Returns:
2097      list[int]: list of IDs of the vertices.
2098
2099    """
2100    return [v.ID for v in self.vertices]
2101
2102
2103  def insert_vertex(self, v:Vertex, predecessor:Vertex) -> list[Edge]:
2104    """Insert vertex after predecessor and return modified edge and new edge.
2105
2106    If the initial edge was (say) (0 1 2 5) and the predecessor was set to 1
2107    the returned edges would be (0 1 v) and (v 2 5).
2108    """
2109    i = self.corners.index(predecessor)
2110    new_edge = Edge([v] + self.corners[(i+1):])
2111    if self.right_tile is not None:
2112      new_edge.right_tile = self.right_tile
2113    if self.left_tile is not None:
2114      new_edge.left_tile = self.left_tile
2115    self.corners = self.corners[:(i+1)] + [v]
2116    self.vertices = [self.vertices[0], v]
2117    self.ID = tuple(v.ID for v in self.vertices)
2118    return [self, new_edge]
2119
2120
2121  def get_geometry(
2122      self,
2123      forward:bool = True,
2124    ) -> geom.LineString:
2125    """Return geom.LineString representing geometry (including corners).
2126
2127    Args:
2128      forward (bool, optional): if True the returned LineString starts at
2129        corners[0], else at corners[-1]. Defaults to True.
2130
2131    Returns:
2132      geom.LineString: the required LineString.
2133
2134    """
2135    if forward:
2136      return geom.LineString([v.point for v in self.corners])
2137    return geom.LineString([v.point for v in self.corners[::-1]])
2138
2139
2140  def get_topology(
2141      self,
2142      forward:bool = True,
2143    ) -> geom.LineString:
2144    """Return LineString connecting vertices of this edge.
2145
2146    Args:
2147      forward (bool, optional): if True LineString starts at vertices[0],
2148        else at vertices[1]. Defaults to True.
2149
2150    Returns:
2151        geom.LineString: the required LineString.
2152
2153    """
2154    if forward:
2155      return geom.LineString([v.point for v in self.vertices])
2156    return geom.LineString([v.point for v in self.vertices[::-1]])

Class to represent edges in a tiling (not tile sides).

Edge(corners: list[Vertex])
2048  def __init__(
2049      self,
2050      corners:list[Vertex],
2051    ) -> None:
2052    """Class constructor.
2053
2054    Initialises the corners and vertices lists and sets ID to (vertices[0].ID,
2055    vertices[1].ID). The vertices list is all the corners with is_tiling_vertex
2056    property True -- Note that during initialisation the default of this
2057    property is True until after the relations between tiles and vertices have
2058    been determined.
2059
2060    Args:
2061      corners (list[Vertex]): list of all corners along the edge.
2062
2063    """
2064    self.corners = corners
2065    self.vertices = [v for v in self.corners if v.is_tiling_vertex]
2066    self.ID = tuple(v.ID for v in self.vertices)

Class constructor.

Initialises the corners and vertices lists and sets ID to (vertices[0].ID, vertices[1].ID). The vertices list is all the corners with is_tiling_vertex property True -- Note that during initialisation the default of this property is True until after the relations between tiles and vertices have been determined.

Args: corners (list[Vertex]): list of all corners along the edge.

ID: tuple[int]

IDs of the vertices at ends of the edge. Used as key in the containing Topology's edges dictionary.

vertices: list[Vertex]

two item list of the end vertices.

corners: list[Vertex]

list of all the vertices in the edge (including its end vertices). In a 'normal' edge to edge tiling corners and vertices will be identical.

right_tile: Tile = None

the tile to the right of the edge traversed from its first to its last vertex. Given clockwise winding default, all edges will have a right_tile.

left_tile: Tile = None

the tile to the left of the edge traversed from its first to its last vertex. Exterior edges of the tiles in a Topology will not have a left_tile.

base_ID: tuple[int] = (1000000, 1000000)

ID of corresponding edge in the base tileable

transitivity_class: int = None

transitivity class of the edge under symmetries of the tiling

label: str = ''

the (lower case letter) label of the edge under the symmetries of the tiling.

def get_corner_IDs(self) -> list[int]:
2083  def get_corner_IDs(self) -> list[int]:
2084    """Return the IDs of edge corners.
2085
2086    Returns:
2087      list[int]: IDs of all corners.
2088
2089    """
2090    return [c.ID for c in self.corners]

Return the IDs of edge corners.

Returns: list[int]: IDs of all corners.

def get_vertex_IDs(self) -> list[int]:
2093  def get_vertex_IDs(self) -> list[int]:
2094    """Return IDs of edge vertices.
2095
2096    Returns:
2097      list[int]: list of IDs of the vertices.
2098
2099    """
2100    return [v.ID for v in self.vertices]

Return IDs of edge vertices.

Returns: list[int]: list of IDs of the vertices.

def insert_vertex( self, v: Vertex, predecessor: Vertex) -> list[Edge]:
2103  def insert_vertex(self, v:Vertex, predecessor:Vertex) -> list[Edge]:
2104    """Insert vertex after predecessor and return modified edge and new edge.
2105
2106    If the initial edge was (say) (0 1 2 5) and the predecessor was set to 1
2107    the returned edges would be (0 1 v) and (v 2 5).
2108    """
2109    i = self.corners.index(predecessor)
2110    new_edge = Edge([v] + self.corners[(i+1):])
2111    if self.right_tile is not None:
2112      new_edge.right_tile = self.right_tile
2113    if self.left_tile is not None:
2114      new_edge.left_tile = self.left_tile
2115    self.corners = self.corners[:(i+1)] + [v]
2116    self.vertices = [self.vertices[0], v]
2117    self.ID = tuple(v.ID for v in self.vertices)
2118    return [self, new_edge]

Insert vertex after predecessor and return modified edge and new edge.

If the initial edge was (say) (0 1 2 5) and the predecessor was set to 1 the returned edges would be (0 1 v) and (v 2 5).

def get_geometry(self, forward: bool = True) -> shapely.geometry.linestring.LineString:
2121  def get_geometry(
2122      self,
2123      forward:bool = True,
2124    ) -> geom.LineString:
2125    """Return geom.LineString representing geometry (including corners).
2126
2127    Args:
2128      forward (bool, optional): if True the returned LineString starts at
2129        corners[0], else at corners[-1]. Defaults to True.
2130
2131    Returns:
2132      geom.LineString: the required LineString.
2133
2134    """
2135    if forward:
2136      return geom.LineString([v.point for v in self.corners])
2137    return geom.LineString([v.point for v in self.corners[::-1]])

Return geom.LineString representing geometry (including corners).

Args: forward (bool, optional): if True the returned LineString starts at corners[0], else at corners[-1]. Defaults to True.

Returns: geom.LineString: the required LineString.

def get_topology(self, forward: bool = True) -> shapely.geometry.linestring.LineString:
2140  def get_topology(
2141      self,
2142      forward:bool = True,
2143    ) -> geom.LineString:
2144    """Return LineString connecting vertices of this edge.
2145
2146    Args:
2147      forward (bool, optional): if True LineString starts at vertices[0],
2148        else at vertices[1]. Defaults to True.
2149
2150    Returns:
2151        geom.LineString: the required LineString.
2152
2153    """
2154    if forward:
2155      return geom.LineString([v.point for v in self.vertices])
2156    return geom.LineString([v.point for v in self.vertices[::-1]])

Return LineString connecting vertices of this edge.

Args: forward (bool, optional): if True LineString starts at vertices[0], else at vertices[1]. Defaults to True.

Returns: geom.LineString: the required LineString.