Module weavingspace.topology
Together the Topology
, Tile
,
Vertex
, Edge
,
and Symmetries
classes enable extraction of the
topological structure of periodic 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, as at Feb 2024 it is not such a data structure. Instead
usage is
tile = TileUnit(...)
topology = Topology(tile)
topology = topology.transform_*(...)
new_tile = topology.tile_unit
Topology plot function is necessary 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 also that these classes do not accurately represent the distinctions made in the mathematical literature between tiling vertices and tile corners, or between tiling edges and tile sides.
Classes
class Topology (unit: Tileable,
ignore_tile_ids: bool = True)-
Expand source code
class Topology: """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]. """ tileable: Tileable """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[int, Edge] """dictionary of the tiling edges, keyed by Edge ID.""" unique_tile_shapes: list[geom.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[geom.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 __init__(self, unit: Tileable, ignore_tile_ids:bool = True): """Class constructor. Args: unit (Tileable): the Tileable whose topology is required. """ # Note that the order of these setup steps is fragile sometimes # not obviously so. self.tileable = unit # keep this for reference self.n_tiles = self.tileable.tiles.shape[0] self._initialise_points_into_tiles() self._setup_vertex_tile_relations() self._setup_edges() self._copy_base_tiles_to_patch() self._assign_vertex_and_edge_base_IDs() self._identify_distinct_tile_shapes(ignore_tile_ids) self._find_tile_transitivity_classes() self._find_vertex_transitivity_classes() self._find_edge_transitivity_classes() self._label_tiles() # now no longer required... self.generate_dual() def __str__(self) -> str: """Returns string representation of this Topology. Returns: str: a message that recommends examining the tiles, points and edges attributes. """ return f"""Topology of Tileable with {self.n_tiles} tiles.\n Examine .tiles, .points and .edges for more details.""" def __repr__(self) -> str: return str(self) def _initialise_points_into_tiles(self, debug:bool = False) -> None: """Sets up dictionary of unique point locations in the tiling and assigns them to Tiles. Args: debug (bool): if True prints useful debugging information. """ shapes = self.tileable.get_local_patch(r = 1, include_0 = True).geometry shapes = [tiling_utils.get_clean_polygon(s) for s in shapes] self.n_1_order_patch = len(shapes) labels = list(self.tileable.tiles.tile_id) * (len(shapes) // self.n_tiles) self.tiles = [] self.points = {} for (i, shape), label in zip(enumerate(shapes), labels): tile = Tile(i) tile.label = label tile.base_ID = tile.ID % self.n_tiles self.tiles.append(tile) tile.corners = [] corners = tiling_utils.get_corners(shape, repeat_first = False) for c in corners: prev_vertex = None for p in reversed(self.points.values()): if c.distance(p.point) <= 2 * tiling_utils.RESOLUTION: # found an already existing vertex, so add to the tile and break prev_vertex = p tile.corners.append(prev_vertex) break if prev_vertex is None: # new vertex, add it to the topology dictionary and to the tile v = self.add_vertex(c) tile.corners.append(v) if debug: print(f"Added new Vertex {v} to Tile {i}") def _setup_vertex_tile_relations(self, debug:bool = False): """Determines relations between vertices and tiles. In particular vertices along tile edges that are not yet included in their list of vertices are added. Meanwhile vertex lists of incident tiles and neighbours are set up. Args: debug (bool): if True prints debugging information. """ # we do this for all tiles in the radius-1 local patch for tile in self.tiles: if debug: print(f"Checking for vertices incident on Tile {tile.ID}") corners = [] # performance is much improved using the vertex IDs initial_corner_IDs = tile.get_corner_IDs() # we need the current shape (not yet set) to check for incident vertices shape = geom.Polygon([c.point for c in tile.corners]) # get points incident on tile boundary, not already in the tile corners new_points = [v for v in self.points.values() if v.ID not in initial_corner_IDs and v.point.distance(shape) <= 2 * tiling_utils.RESOLUTION] # iterate over sides of the tile to see which the vertex is incident on for c1, c2 in zip(tile.corners, tile.corners[1:] + tile.corners[:1]): all_points = [c1, c2] if len(new_points) > 0: if debug: print(f"{[v.ID for v in new_points]} incident on tile") ls = geom.LineString([c1.point, c2.point]) to_insert = [v for v in new_points if v.point.distance(ls) <= 2 * tiling_utils.RESOLUTION] if len(to_insert) > 0: # sort by distance along the side d_along = sorted([(ls.line_locate_point(v.point), v) for v in to_insert], key = lambda x: x[0]) to_insert = [v for d, v in d_along] all_points = all_points[:1] + to_insert + all_points[1:] corners.extend(all_points[:-1]) for x1, x2 in zip(all_points[:-1], all_points[1:]): # x2 will add the tile and neigbour when we get to the next side # every vertex gets a turn! x1.add_tile(tile) x1.add_neighbour(x2.ID) tile.corners = corners tile.set_shape_from_corners() def _setup_edges(self, debug:bool = False): """Sets up the tiling edges. First vertices in the base tiles are classified as tiling vertices or not -- only these can be classified reliably (e.g vertices on the perimeter are tricky).. Up to here all vertices have been considered tiling vertices. Second edges are created by traversing tile corner lists. Edges are stored once only by checking for edges in the reverse direction already in the edges dictionary. Edge right and left tiles are initialised. Third tile edge direction lists are initialised. """ # classify vertices in the base tiles for tile in self.tiles[:self.n_tiles]: for v in tile.corners: v.is_tiling_vertex = len(v.neighbours) > 2 if debug: print(f"Classified base tile vertices") self.edges = {} for tile in self.tiles: if debug: print(f"Adding edges from Tile {tile.ID}") tile.edges = [] vertices = [v for v in tile.corners if v.is_tiling_vertex] # note that through here finding ints in lists is much faster than # finding Vertex objects, hence we use lists of IDs not Vertex objects if len(vertices) > 1: for v1, v2 in zip(vertices, vertices[1:] + vertices[:1]): corner_IDs = tile.get_corner_IDs() idx1 = corner_IDs.index(v1.ID) idx2 = corner_IDs.index(v2.ID) if idx1 < idx2: corners = [c for c in corner_IDs[idx1:(idx2 + 1)]] else: corners = [c for c in corner_IDs[idx1:] + corner_IDs[:(idx2 + 1)]] ID = (corners[0], corners[-1]) if not ID in self.edges: # check that reverse direction edge is not present first r_ID = ID[::-1] if r_ID in self.edges: # if it is, then add it and set left_tile if debug: print(f"reverse edge {r_ID} found") e = self.edges[r_ID] e.left_tile = tile tile.edges.append(e) else: # we've found a new edge so make and add it if debug: print(f"adding new edge {corners}") e = self.add_edge([self.points[c] for c in corners]) e.right_tile = tile tile.edges.append(e) # initialise the edge direction information in the tile tile.set_edge_directions() def _assign_vertex_and_edge_base_IDs(self): """Assigns the base_ID attributes of vertices and edges. These allow us to determine corrspondences between vertices and edges in the 'base' tiles in the Topology tileable, and those we have added at radius 1 for labelling and visualisation. """ self._assign_vertex_base_IDs(True, False) self._assign_vertex_base_IDs(False, True) self._assign_edge_base_IDs() def _assign_vertex_base_IDs(self, first_pass:bool = True, last_pass:bool = False): """Assigns base_ID attribute of vertices. Two passes are required. TODO: consider if there is a networkx.connected_communities option here, since this is in essence about finding connected groups of tiles, vertices and edges. At very least, the need to run the code twice suggests that a more elegant approach is out there... Args: first_pass (bool, optional): it True the core vertices on the tiles in the Topology tileable are assigned; if False this step is skipped and IDs are propagated to neighbours. Defaults to True. last_pass (bool, optional): if True, it is the last pass and IDs are finally assigned based on the lowest ID assigned in previous rounds. Defaults to False. """ if first_pass: for i, v in enumerate(self.vertices_in_tiles(self.tiles[:self.n_tiles])): v.base_ID = set([i]) for tile in self.tiles: for v, vb in zip(tile.corners, self.tiles[tile.base_ID].corners): if v.base_ID == v.ID: # 1_000_000: v.base_ID = set([x for x in vb.base_ID]) else: v.base_ID = v.base_ID.union(vb.base_ID) if last_pass: for tile in self.tiles: for v in tile.corners: if isinstance(v.base_ID, set): v.base_ID = min(v.base_ID) def _assign_edge_base_IDs(self): """Assigns base_ID attribute of edges, based on the base_ID attributes of vertices. This might be oversimplified... """ for tile in self.tiles: for e, eb in zip(tile.edges, self.tiles[tile.base_ID].edges): v0 = self.points[eb.ID[0]].base_ID v1 = self.points[eb.ID[1]].base_ID e.base_ID = (v0, v1) def _copy_base_tiles_to_patch(self): """Copies attributes of base tiles to their corresponding tiles in the radius-1 patch. This requires: First, inserting any additional corners in the base tiles not found in the radius-1 tiles. Second, any vertices in the base tiles that are NOT tiling vertices are applied to radius-1 tiles leading to merging of some edges. """ # the number of tiles in the base + radius-1 n_r1 = len(self.tiles) # first add any missing vertices to the non-base tiles # we add all the missing vertices before doing any merges for base in self.tiles[:self.n_tiles]: for other in self.tiles[base.ID:n_r1:self.n_tiles]: self._match_reference_tile_vertices(base, other) # then merge any edges that meet at a corner for base in self.tiles[:self.n_tiles]: for other in self.tiles[base.ID:n_r1:self.n_tiles]: self._match_reference_tile_corners(base, other) def _match_reference_tile_vertices(self, tile1:Tile, tile2:Tile): """Adds vertices to tile2 so that it matches tile1 adjusting edges as required. This assumes the tiles are the same shape, but that tile2 may be missing some tiling vertices along some edges. Args: tile1 (Tile): reference tile. tile2 (Tile): tile to change. """ to_add = len(tile1.corners) - len(tile2.corners) while to_add > 0: # find the reference x-y offset dxy = (tile2.centre.x - tile1.centre.x, tile2.centre.y - tile1.centre.y) for i, t1c in enumerate([c.point for c in tile1.corners]): t2c = tile2.corners[i % len(tile2.corners)].point if abs((t2c.x - t1c.x) - dxy[0]) > 10 * tiling_utils.RESOLUTION or \ abs((t2c.y - t1c.y) - dxy[1]) > 10 * tiling_utils.RESOLUTION: # add vertex to t2 by copying the t1 vertex appropriately offset # note that this might alter the length of t2.corners v = self.add_vertex(geom.Point(t1c.x + dxy[0], t1c.y + dxy[1])) v.is_tiling_vertex = True old_edge, new_edges = tile2.insert_vertex_at(v, i) del self.edges[old_edge] for e in new_edges: self.edges[e.ID] = e to_add = to_add - 1 def _match_reference_tile_corners(self, tile1:Tile, tile2:Tile): """Finds vertices that are corners in tile2 but vertices in tile1 and updates tile2 to match - merging edges as required. Args: tile1 (Tile): reference tile. tile2 (Tile): tile to make match. """ vs_to_change = [vj for vi, vj in zip(tile1.corners, tile2.corners) if not vi.is_tiling_vertex and vj.is_tiling_vertex] if len(vs_to_change) > 0: for v in vs_to_change: v.is_tiling_vertex = False # it's a corner not an edge so will have no more than 2 v.tiles old_edges, new_edge = v.tiles[0].merge_edges_at_vertex(v) for e in old_edges: del self.edges[e] self.edges[new_edge.ID] = new_edge def _identify_distinct_tile_shapes(self, ignore_tile_id_labels:bool = True): """Determines unique tiles based on their symmetries and shapes. At the same time assembles a list of the affine transforms under which matches occurs since these are potential symmetries of the tiling. TODO: reimplement consideration of tile_id Args: ignore_tile_id_labels (bool): if True only the shape of tiles matters; if False the tile_id label is also considered. Defaults to True. """ matches = {} offsets = {} for tile in self.tiles[:self.n_tiles]: matches[tile.base_ID] = [tile.base_ID] matched = False s = Symmetries(tile.shape) for other in self.tiles[:self.n_tiles]: if other.ID > tile.ID: offset = s.get_corner_offset(other.shape) if not offset is None: offsets[tile.base_ID] = offset matches[tile.base_ID].append(other.base_ID) matched = True if not matched: offsets[tile.base_ID] = 0 base_groups = list(nx.connected_components(nx.from_dict_of_lists(matches))) self.shape_groups = [] for i, group in enumerate(base_groups): full_group = [] for tile in self.tiles: if tile.base_ID in group: tile.shape_group = i tile.offset_corners(offsets[tile.base_ID]) full_group.append(tile.ID) self.shape_groups.append(full_group) def _find_tile_transitivity_classes(self): """Finds tiles equivalent under symmetries, at the same time updating the tile_matching_transforms attribute to contain only those transforms that pass this test. """ self.tile_matching_transforms = self.get_potential_symmetries() base_tiles = [t for t in self.tiles[:self.n_tiles]] # it is quicker (should be!) to only do within shape group tests # often there is only one when it will make no difference by_group_equivalent_tiles = [] # maintain a set of transforms still potentially tiling symmetries poss_transforms = set(self.tile_matching_transforms.keys()) # and a dictionary of booleans tracking which transforms are still valid eq_under_transform = {tr: True for tr in poss_transforms} for g, group in enumerate(self.shape_groups): by_group_equivalent_tiles.append(set()) source_tiles = [tile for tile in base_tiles if tile.shape_group == g] target_tiles = [tile for tile in self.tiles if tile.shape_group == g] for tr in poss_transforms: transform = self.tile_matching_transforms[tr].transform matched_tiles = {} eq_under_transform[tr] = True for source_tile in source_tiles: matched_tile_id = self._match_geoms_under_transform( source_tile, target_tiles, transform) if matched_tile_id == -1: eq_under_transform[tr] = False break else: matched_tiles[source_tile.ID] = matched_tile_id if eq_under_transform[tr]: for k, v in matched_tiles.items(): # print(f"src {k} tgt {v} {tr=} {transform}") # here we record the transform, in case it is later invalidated by_group_equivalent_tiles[g].add((tr, k, v)) # remove valid transforms that didn't make it through this group poss_transforms = set([t for t, x in eq_under_transform.items() if x]) # compile equivalences from all groups made under still valid transforms # a dict of sets so singletons aren't lost in finding connected components equivalents = {i: set() for i in range(self.n_tiles)} for group_equivalents in by_group_equivalent_tiles: for (tr, tile_i, tile_j) in group_equivalents: if tr in poss_transforms: equivalents[tile_i].add(tile_j) self.tile_matching_transforms = { k: v for k, v in self.tile_matching_transforms.items() if k in poss_transforms} self.tile_transitivity_classes = [] equivalents = nx.connected_components(nx.from_dict_of_lists(equivalents)) for c, base_IDs in enumerate(equivalents): transitivity_class = [] for tile in self.tiles: if tile.base_ID in base_IDs: transitivity_class.append(tile.ID) tile.transitivity_class = c self.tile_transitivity_classes.append(transitivity_class) def get_potential_symmetries(self) -> dict[int, tuple[float]]: """Assembles potential symmetries of the tiling from symmetries of the tileable.prototile and of the tileable.tiles. Removes any duplicates that result. 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. """ self.tile_matching_transforms = {} n_symmetries = 0 pt = self.tileable.prototile.geometry[0] for tr in Shape_Matcher(pt).get_polygon_matches(pt): if not tr.transform_type in ["identity", "translation"]: self.tile_matching_transforms[n_symmetries] = tr n_symmetries = n_symmetries + 1 for tile in self.tiles[:self.n_tiles]: for tr in Shape_Matcher(tile.shape).get_polygon_matches(tile.shape): if not tr.transform_type in ["identity", "translation"]: self.tile_matching_transforms[n_symmetries] = tr n_symmetries = n_symmetries + 1 for tile in self.tiles[:self.n_tiles]: sm = Shape_Matcher(tile.shape) transforms = [sm.get_polygon_matches(self.tiles[i].shape) for i in self.shape_groups[tile.shape_group] if i < self.n_tiles] for tr in itertools.chain(*transforms): if not tr.transform_type in ["identity", "translation"]: self.tile_matching_transforms[n_symmetries] = tr n_symmetries = n_symmetries + 1 self.tile_matching_transforms = self._remove_duplicate_symmetries( self.tile_matching_transforms) return self.tile_matching_transforms def _remove_duplicate_symmetries(self, transforms:dict[int,Any]): """Filters the supplied list of shapely affine transforms so that no duplicates are retained. """ uniques = {} for k, v in transforms.items(): already_exists = False for i, u in uniques.items(): already_exists = np.allclose( v.transform, u.transform, atol = 1e-4, rtol = 1e-4) if already_exists: break if not already_exists: uniques[k] = v return uniques def _find_vertex_transitivity_classes(self): """Finds vertex transitivity classes by checking which vertices align with which others under transforms in the tile_matching_transforms attribute. The process need only determine the classes for vertices in the core tileable.tiles, then assign those to all vertices by matched base_ID. TODO: carefully consider here whether labelling only the tiling vertices is the right thing to do or not... """ equivalent_vertices = defaultdict(set) base_vertices = [v for v in self.vertices_in_tiles(self.tiles[:self.n_tiles]) if v.is_tiling_vertex] for transform in self.tile_matching_transforms.values(): for v in base_vertices: match_ID = self._match_geoms_under_transform( v, base_vertices, transform.transform) if match_ID != -1: equivalent_vertices[v.ID].add(match_ID) equivalent_vertices = self._get_exclusive_supersets( [tuple(sorted(s)) for s in equivalent_vertices.values()]) self.vertex_transitivity_classes = defaultdict(list) for c, vclass in enumerate(equivalent_vertices): for v in self.points.values(): if v.base_ID in vclass: v.transitivity_class = c self.vertex_transitivity_classes[c].append(v.ID) self.vertex_transitivity_classes = list( self.vertex_transitivity_classes.values()) # label vertices based on their transitivity class for v in self.points.values(): if v.is_tiling_vertex: v.label = ALPHABET[v.transitivity_class] def _find_edge_transitivity_classes(self): """Finds edge transitivity classes by checking which edges align with which others under transforms in the tile_matching_transforms attribute. The process need only determine the classes for edges in the core tileable.tiles, then assign those to all edges by matched base_ID. TODO: Note that this code is identical to the vertex transitivity code so it might make sense to merge. """ equivalent_edges = defaultdict(set) base_edges = self.edges_in_tiles(self.tiles[:self.n_tiles]) for transform in self.tile_matching_transforms.values(): for e in base_edges: match_id = self._match_geoms_under_transform( e, base_edges, transform.transform) if match_id != -1: equivalent_edges[e.ID].add(match_id) equivalent_edges = self._get_exclusive_supersets( [tuple(sorted(s)) for s in equivalent_edges.values()]) self.edge_transitivity_classes = defaultdict(list) for c, eclass in enumerate(equivalent_edges): for e in self.edges.values(): if e.base_ID in eclass: e.transitivity_class = c self.edge_transitivity_classes[c].append(e.ID) self.edge_transitivity_classes = list( self.edge_transitivity_classes.values()) # label edges based on their transitivity class for e in self.edges.values(): e.label = alphabet[e.transitivity_class] def _match_geoms_under_transform( self, geom1:Union[Tile,Vertex,Edge], geoms2:list[Union[Tile,Vertex,Edge]], transform:tuple[float]) -> int: """Determines if there is a geometry in the supplied patch onto which the supplied geometry is mapped by the supplied symmetry. Args: tile (Union[Tile,Vertex,Edge]): element whose geometry we want to match. patch (list[Union[Tile,Vertex,Edge]]): set of elements among which a match is sought. transform (tuple[float]): shapely affine transform 6-tuple to apply. Returns: Union[int,tuple[int]]: ID of the element in patch that matches the geom1 element under the transform if one exists, otherwise returns -1. """ match_id = -1 for geom2 in geoms2: if isinstance(geom1, Tile): # an area of intersection based test match = tiling_utils.geometry_matches( affine.affine_transform(geom1.shape, transform), geom2.shape) elif isinstance(geom1, Vertex): # distance test match = affine.affine_transform(geom1.point, transform).distance( geom2.point) <= 10 * tiling_utils.RESOLUTION else: # must be an Edge # since edges _should not_ intersect this test should work in # lieu of a more complete point by point comparison c1 = geom1.get_geometry().centroid c2 = geom2.get_geometry().centroid match = affine.affine_transform(c1, transform) \ .distance(c2) <= 10 *tiling_utils.RESOLUTION if match: return geom2.base_ID return match_id def _get_exclusive_supersets( self, sets:list[Iterable[Any]]) -> list[Iterable[Any]]: """For the supplied list of lists, which may share elements, i.e. they are non-exclusives sets, returns a list of lists which are exclusive: each element will only appear in one of the lists in the returned list. Uses networkx connected components function to achieve this based on a graph where an intersection between two sets is an edge. Args: sets (list[Iterable[Any]]): list of lists of possibly overlapping sets. Returns: list[Iterable[Any]]: list of lists that include all the original elements without overlaps. """ overlaps = [] for i, si in enumerate(sets): s1 = set(si) for j, sj in enumerate(sets): s2 = set(sj) if len(s1 & s2) > 0: overlaps.append((i, j)) G = nx.from_edgelist(overlaps) result = [] for component in nx.connected_components(G): s = set() for i in component: s = s.union(sets[i]) result.append(tuple(s)) return result def vertices_in_tiles(self, tiles:list[Tile]) -> list[Vertex]: """Gets the vertices from self.points that are incident on the tiles in the supplied list. Args: tiles (list[Tile]): tiles whose vertices are required. Returns: list[Vertex]: the required vertices. """ vs = set() for tile in tiles: vs = vs.union(tile.get_corner_IDs()) return [self.points[v] for v in vs] def edges_in_tiles(self, tiles:list[Tile]) -> list[Edge]: """Gets the edges from self.edges that are part of the boundary of tiles in the supplied list. Args: tiles (list[Tile]): tiles whose edges are required. Returns: list[Edge]: the required edges. """ es = set() for tile in tiles: es = es.union(tile.get_edge_IDs()) return [self.edges[e] for e in es] def generate_dual(self) -> list[geom.Polygon]: """Create the dual tiiing for the tiling of this Topology. TODO: make this a viable replacement for the existing dual tiling generation. Returns: list[geom.Polygon]: a list of polygon objects. """ for v in self.points.values(): v.clockwise_order_incident_tiles() self.dual_tiles = {} base_id_sets = defaultdict(list) for v in self.points.values(): base_id_sets[v.base_ID].append(v.ID) minimal_set = [self.points[min(s)] for s in base_id_sets.values()] for v in minimal_set: # for v in self.points.values(): if v.is_interior() and len(v.tiles) > 2: self.dual_tiles[v.ID] = \ geom.Polygon([t.centre for t in v.tiles]) def add_vertex(self, pt:geom.Point) -> Vertex: """Adds a Vertex at the specified point location, returning it to the caller. No attempt is made to ensure Vertex IDs are an unbroken sequemce: 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. """ n = 0 if len(self.points) == 0 else max(self.points.keys()) + 1 v = Vertex(pt, n) self.points[n] = v return v def add_edge(self, vs:list[Vertex]) -> Edge: """Adds an Edge to the edges dictionary. Edges are self indexing by the IDs of their end Vertices. Returns the new Edge to the caller. Args: vs (list[Vertex]): list of Vertices in the Edge to be created. Returns: Edge: the added Edge. """ e = Edge(vs) self.edges[e.ID] = e return e def _get_tile_geoms(self) -> gpd.GeoDataFrame: return gpd.GeoDataFrame( data = {"transitivity_class": [t.transitivity_class for t in self.tiles]}, geometry = gpd.GeoSeries([t.shape for t in self.tiles])) def _get_tile_centre_geoms(self) -> gpd.GeoSeries: return gpd.GeoSeries([t.centre for t in self.tiles]) def _get_point_geoms(self) -> gpd.GeoSeries: return gpd.GeoSeries([v.point for v in self.points.values()]) def _get_vertex_geoms(self) -> gpd.GeoSeries: return gpd.GeoSeries([v.point for v in self.points.values() if v.is_tiling_vertex]) def _get_edge_geoms(self, offset:float = 0.0) -> gpd.GeoSeries: return gpd.GeoSeries([e.get_topology().parallel_offset(offset) for e in self.edges.values()]) def plot(self, show_original_tiles: bool = True, show_tile_centres: bool = False, show_tile_vertex_labels: bool = False, show_tile_edge_labels: bool = False, show_vertex_ids: bool = False, show_vertex_labels: bool = True, show_edges: bool = True, offset_edges: bool = True, show_edge_labels:bool = False, show_dual_tiles: bool = False) -> pyplot.Axes: fig = pyplot.figure(figsize = (10, 10)) ax = fig.add_axes(111) extent = gpd.GeoSeries([t.shape for t in self.tiles]).total_bounds dist = max([extent[2] - extent[0], extent[3] - extent[1]]) if show_original_tiles: self._get_tile_geoms().plot(column = "transitivity_class", cmap = "Set2", ax = ax, ec = "#444444", alpha = 0.2, lw = 0.75) if show_tile_centres: for i, tile in enumerate(self.tiles): ax.annotate(i, xy = (tile.centre.x, tile.centre.y), color = "b", fontsize = 10, ha = "center", va = "center") if show_vertex_labels: for v in self.points.values(): ax.annotate(v.ID if show_vertex_ids else v.label, xy = (v.point.x, v.point.y), color = "r", fontsize = 10, ha = "center", va = "center", bbox = dict(boxstyle="circle", lw=0, fc="#ffffff40")) if show_tile_vertex_labels: for tile in self.tiles: for posn, label in zip(tile.get_vertex_label_positions(), tile.vertex_labels): ax.annotate(label, xy = (posn.x, posn.y), fontsize = 8, ha = "center", va = "center", color = "b") if show_tile_edge_labels: for tile in self.tiles: for posn, label in zip(tile.get_edge_label_positions(), tile.edge_labels): ax.annotate(label, xy = (posn.x, posn.y), color = "b", ha = "center", va = "center", fontsize = 8) if show_edge_labels: if show_edges: edges = self._get_edge_geoms(dist / 100 if offset_edges else 0) edges.plot(ax = ax, color = "forestgreen", ls = "dashed", lw = 1) else: edges = [e.get_geometry() for e in self.edges.values()] for l, e in zip(edges, self.edges.values()): c = l.centroid ax.annotate(e.label, xy = (c.x, c.y), ha = "center", va = "center", fontsize = 10 if show_edge_labels else 7, color = "k") if show_dual_tiles: gpd.GeoSeries(self.dual_tiles).buffer( -dist / 400, join_style = 2, cap_style = 3).plot( ax = ax, fc = "k", alpha = 0.2) pyplot.axis("off") return ax def plot_tiling_symmetries(self, **kwargs): n = len(self.tile_matching_transforms) nc = int(np.ceil(np.sqrt(n))) nr = int(np.ceil(n / nc)) gs = gpd.GeoSeries([t.shape for t in self.tiles]) gsb = gs[:self.n_tiles] fig = pyplot.figure(figsize = (12, 12 * nr / nc)) for i, tr in enumerate(self.tile_matching_transforms.values()): ax = fig.add_subplot(nr, nc, i + 1) gs.plot(ax = ax, fc = "b", alpha = 0.15, ec = "k", lw = 0.5) gsb.plot(ax = ax, fc = "#00000000", ec = "w", lw = 1, zorder = 2) gsm = gpd.GeoSeries([tr.apply(g) for g in gsb]) gsm.plot(ax = ax, fc = "r", alpha = 0.2, lw = 0, ec = "r") tr.draw(ax, **kwargs) pyplot.axis("off") def transform_geometry(self, new_topology:bool, apply_to_tiles:bool, selector:str, type:str, **kwargs) -> "Topology": r"""Applies a specified transformation of elements in the Topology whose labels match the selector parameter, optionally applying the transform to update tile and optionally returning a new Topology object (or applying it to this one). 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". type (str): name of the type of transformation requested. Currently supported are `zigzag_edge`, `rotate_edge`, `push_vertex`, and `nudge_vertex`. Keyword arguments for each are documented in the corresponding methods. 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. """ print( f"""CAUTION: new Topology will probably not be correctly labelled. To build a correct Topology, extract the tileable attribute and rebuild Topology from that. """) topo = pickle.loads(pickle.dumps(self)) if new_topology else self transform_args = topo.get_kwargs(getattr(topo, type), **kwargs) if type == "zigzag_edge": for e in topo.edges.values(): if e.label in selector: topo.zigzag_edge(e, **transform_args) # --------------------------- elif type == "rotate_edge": for e in topo.edges.values(): if e.label in selector: topo.rotate_edge(e, **transform_args) # --------------------------- elif type == "scale_edge": for e in topo.edges.values(): if e.label in selector: topo.scale_edge(e, **transform_args) # --------------------------- elif type == "push_vertex": pushes = {} for v in topo.vertices_in_tiles(topo.tiles[:topo.n_tiles]): if v.label in selector: pushes[v.base_ID] = topo.push_vertex(v, **transform_args) for base_ID, (dx, dy) in pushes.items(): for v in [v for v in topo.points.values() if v.base_ID == base_ID]: v.point = affine.translate(v.point, dx, dy) # --------------------------- elif type == "nudge_vertex": for v in topo.points.values(): if v.label in selector: topo.nudge_vertex(v, **transform_args) if apply_to_tiles: for t in topo.tiles: t.set_corners_from_edges() topo.tileable.tiles.geometry = gpd.GeoSeries( [topo.tiles[i].shape for i in range(topo.n_tiles)]) topo.tileable.setup_regularised_prototile_from_tiles() return topo def zigzag_edge(self, edge:Edge, start:str = "A", sf:float = 1.0, n:int = 2, h:float = 0.5, smoothness:int = 0): """Applies a zigzag transformation to the 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 n (int, optional): number of zigs and zags in the edge. Defaults to 2. 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'. 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. """ v0, v1 = edge.vertices[0], edge.vertices[1] if n % 2 == 1 and v0.label != start: h = -h ls = tiling_utils.zigzag_between_points(v0.point, v1.point, n, h, smoothness) # remove current corners self.points = {k: v for k, v in self.points.items() if not k in edge.get_corner_IDs()[1:-1]} # add the new ones new_corners = [self.add_vertex(geom.Point(xy)) for xy in ls.coords] edge.corners = edge.vertices[:1] + new_corners + edge.vertices[-1:] if not edge.right_tile is None: edge.right_tile.set_corners_from_edges(False) if not edge.left_tile is None: edge.left_tile.set_corners_from_edges(False) def rotate_edge(self, edge:Edge, centre:str = "A", angle:float = 0) -> None: v0, v1 = edge.vertices[0], edge.vertices[1] ls = geom.LineString([v0.point, v1.point]) if v0.label == centre: c = v0.point elif v1.label == centre: c = v1.point else: c = ls.centroid ls = affine.rotate(ls, angle, origin = c) v0.point, v1.point = [geom.Point(c) for c in ls.coords] def scale_edge(self, edge:Edge, sf:float = 1.0) -> None: v0, v1 = edge.vertices[0], edge.vertices[1] ls = geom.LineString([v0.point, v1.point]) ls = affine.scale(ls, xfact = sf, yfact = sf, origin = ls.centroid) v0.point, v1.point = [geom.Point(c) for c in ls.coords] def push_vertex(self, vertex:Vertex, push_d:float) -> tuple[float]: neighbours = [self.points[v] for v in vertex.neighbours] dists = [vertex.point.distance(v.point) for v in neighbours] x, y = vertex.point.x, vertex.point.y unit_vectors = [((x - v.point.x) / d, (y - v.point.y) / d) for v, d in zip(neighbours, dists)] return (push_d * sum([xy[0] for xy in unit_vectors]), push_d * sum([xy[1] for xy in unit_vectors])) def nudge_vertex(self, vertex:Vertex, dx:float, dy:float): vertex.point = affine.translate(vertex.point, dx, dy) def get_kwargs(self, fn:Callable, **kwargs) -> dict[Any]: args = list(inspect.signature(fn).parameters) return {k: kwargs.pop(k) for k in dict(kwargs) if k in args} # =========================================================================== # potentially redundant code # =========================================================================== def _label_tiles(self): """Labels the base tile vertices and edges at a per tile level, then copies to corresponding tiles in the radius-1 patch. NOTE: this is effectively legacy code, since its purpose originally was as a (flawed) basis for determining vertex and edge labels. """ first_letter = 0 for i, group in enumerate(self.shape_groups): tiles = [t for t in self.tiles[:self.n_tiles] if t.ID in group] s = Symmetries(self.tiles[group[0]].shape) vlabels = list(s.get_unique_labels(offset = first_letter)["rotations"][0]) elabels = self._get_edge_labels_from_vertex_labels(vlabels) for tile in tiles: tile.vertex_labels = vlabels tile.edge_labels = elabels first_letter = first_letter + len(set(vlabels)) for tile in self.tiles: #[self.n_tiles:]: tile.vertex_labels = self.tiles[tile.base_ID].vertex_labels tile.edge_labels = self.tiles[tile.base_ID].edge_labels def _get_edge_labels_from_vertex_labels(self, vlabels:list[str]) -> list[str]: r"""Given a list of vertex labels, returns a list of edge labels such that each distinct pair of vertex labels at the ends of an edge will be given a distinct lower case letter label. E.g., A B C B A will yield a+ b+ b- a- c from AB -> a+ BC -> b+ CB -> b- BA -> a- AA -> c, where + designated CW traversal, - CCW, and no symbol means that edge appears only once. This may yet have use in more elaborate edge transformations. (See G&S 87 on tile incidence symbols.) Args: vlabels (list[str]): ordered list of vertex labels. Returns: list[str]: corresponding list of edge labels. """ AB_labels = [a + b for a, b in zip(vlabels, vlabels[1:] + vlabels[:1])] letter = ALPHABET.index(min(list(vlabels))) edge_labels = {} for AB_label in AB_labels: if not AB_label in edge_labels: if AB_label[::-1] in edge_labels: label = edge_labels[AB_label[::-1]] edge_labels[AB_label] = label + "-" edge_labels[AB_label[::-1]] = label + "+" else: edge_labels[AB_label] = alphabet[letter] letter = letter + 1 return [edge_labels[i] for i in AB_labels] # =========================================================================== # redundant code # =========================================================================== def _label_vertices(self): """Labels vertices based on cycle of tile vertex labels around them. """ uniques = set() # first label vertices in the core tiles vs = set() for t in self.tiles[:self.n_tiles]: vs = vs.union(t.get_corner_IDs()) # Note: sorting is important for repeatable labelling! for vi in sorted(vs): v = self.points[vi] label = "" for tile in v.tiles: label = label + \ tile.vertex_labels[tile.corners.index(v)] # TODO: resolve this question: cyclic sort seems more correct, but # neither approach seems to work in all cases... see esp. cyclic sort # applied to the cheese sandwich tiling. v.label = "".join(self._cyclic_sort_first(list(label))) # v.label = "".join(sorted(list(label))) # v.label = min(list(label)) uniques.add(v.label) for vi in sorted(vs): v = self.points[vi] v.label = ALPHABET[sorted(uniques).index(v.label)] # now copy to corresponding vertices in the rest of tiling for ti, t in enumerate(self.tiles): if ti >= self.n_tiles: t0 = self.tiles[ti % self.n_tiles] for v0, v in zip(t0.corners, t.corners): # Vertices may appear in more than one tile, only label once! if self.points[v.ID].label == "": self.points[v.ID].label = self.points[v0.ID].label def _label_edges(self): """Labels edges based on the tile edge label on each side. """ uniques = set() labelled = set() # first label base tile edges from the central tile unit for t in self.tiles[:self.n_tiles]: for e in t.edges: rt, lt = e.right_tile, e.left_tile rlab, llab = rt.get_edge_label(e), lt.get_edge_label(e) e.label = "".join(sorted([rlab, llab])) uniques.add(e.label) labelled.add(e.ID) for ei in sorted(labelled): e = self.edges[ei] e.label = alphabet[sorted(uniques).index(e.label)] # now copy to corresponding edges in the rest of tiling for ti, t in enumerate(self.tiles): if ti >= self.n_tiles: t0 = self.tiles[ti % self.n_tiles] for e0, e in zip(t0.edges, t.edges): # edges may appear in more than one tile, only label once! if e.label == "": e.label = e0.label def _cyclic_sort_first(self, lst:Iterable) -> Iterable: """Returns supplied Iterable in canonical cyclic sorted form. E.g. the sequence ACABD, yields 5 possible cycles ACABD, CABDA, ABDAC, BDACA, and DACAB. The lexically first of these is ABDAC, which would be returned. Args: lst (Iterable): an Iterable of elements (usu. strings). Returns: Iterable: the lexically first of the possible cycles in the supplied iterable. """ cycle = lst + lst n = len(lst) cycles = [cycle[i:i + n] for i in range(n)] return sorted(cycles)[0]
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].
Class constructor.
Args
unit
:Tileable
- the Tileable whose topology is required.
Class variables
var dual_tiles : list[shapely.geometry.polygon.Polygon]
-
list of geom.Polygons from which a dual tiling might be constructed.
var edge_transitivity_classes : list[list[tuple[int]]]
-
list of lists of edge IDs in each transitivity class
var edges : dict[int, Edge]
-
dictionary of the tiling edges, keyed by Edge ID.
var n_tiles : int
-
number of tiles in the base Tileable (retained for convenience).
var points : dict[int, Vertex]
-
dictionary of all points (vertices and corners) in the tiling, keyed by Vertex ID.
var shape_groups : list[list[int]]
-
list of lists of tile IDs distinguished by shape and optionally tile_id
var tile_matching_transforms : list[tuple[float]]
-
shapely transform tuples that map tiles onto other tiles
var tile_transitivity_classes : list[tuple[int]]
-
list of lists of tile IDs in each transitivity class
var tileable : Tileable
-
the Tileable on which the topology will be based.
var 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).
var 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).
var vertex_transitivity_classes : list[list[int]]
-
list of lists of vertex IDs in each transitivity class
Methods
def add_edge(self,
vs: list[Vertex]) ‑> Edge-
Expand source code
def add_edge(self, vs:list[Vertex]) -> Edge: """Adds an Edge to the edges dictionary. Edges are self indexing by the IDs of their end Vertices. Returns the new Edge to the caller. Args: vs (list[Vertex]): list of Vertices in the Edge to be created. Returns: Edge: the added Edge. """ e = Edge(vs) self.edges[e.ID] = e return e
Adds an Edge to the edges dictionary. Edges are self indexing by the IDs of their end Vertices. Returns the new Edge to the caller.
Args
vs
:list[Vertex]
- list of Vertices in the Edge to be created.
Returns
Edge
- the added Edge.
def add_vertex(self, pt: shapely.geometry.point.Point) ‑> Vertex
-
Expand source code
def add_vertex(self, pt:geom.Point) -> Vertex: """Adds a Vertex at the specified point location, returning it to the caller. No attempt is made to ensure Vertex IDs are an unbroken sequemce: 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. """ n = 0 if len(self.points) == 0 else max(self.points.keys()) + 1 v = Vertex(pt, n) self.points[n] = v return v
Adds a Vertex at the specified point location, returning it to the caller. No attempt is made to ensure Vertex IDs are an unbroken sequemce: 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 edges_in_tiles(self,
tiles: list[Tile]) ‑> list[Edge]-
Expand source code
def edges_in_tiles(self, tiles:list[Tile]) -> list[Edge]: """Gets the edges from self.edges that are part of the boundary of tiles in the supplied list. Args: tiles (list[Tile]): tiles whose edges are required. Returns: list[Edge]: the required edges. """ es = set() for tile in tiles: es = es.union(tile.get_edge_IDs()) return [self.edges[e] for e in es]
Gets the edges from self.edges that are part of the boundary of tiles in the 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]
-
Expand source code
def generate_dual(self) -> list[geom.Polygon]: """Create the dual tiiing for the tiling of this Topology. TODO: make this a viable replacement for the existing dual tiling generation. Returns: list[geom.Polygon]: a list of polygon objects. """ for v in self.points.values(): v.clockwise_order_incident_tiles() self.dual_tiles = {} base_id_sets = defaultdict(list) for v in self.points.values(): base_id_sets[v.base_ID].append(v.ID) minimal_set = [self.points[min(s)] for s in base_id_sets.values()] for v in minimal_set: # for v in self.points.values(): if v.is_interior() and len(v.tiles) > 2: self.dual_tiles[v.ID] = \ 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.
Returns
list[geom.Polygon]
- a list of polygon objects.
def get_kwargs(self, fn: Callable, **kwargs) ‑> dict[typing.Any]
-
Expand source code
def get_kwargs(self, fn:Callable, **kwargs) -> dict[Any]: args = list(inspect.signature(fn).parameters) return {k: kwargs.pop(k) for k in dict(kwargs) if k in args}
def get_potential_symmetries(self) ‑> dict[int, tuple[float]]
-
Expand source code
def get_potential_symmetries(self) -> dict[int, tuple[float]]: """Assembles potential symmetries of the tiling from symmetries of the tileable.prototile and of the tileable.tiles. Removes any duplicates that result. 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. """ self.tile_matching_transforms = {} n_symmetries = 0 pt = self.tileable.prototile.geometry[0] for tr in Shape_Matcher(pt).get_polygon_matches(pt): if not tr.transform_type in ["identity", "translation"]: self.tile_matching_transforms[n_symmetries] = tr n_symmetries = n_symmetries + 1 for tile in self.tiles[:self.n_tiles]: for tr in Shape_Matcher(tile.shape).get_polygon_matches(tile.shape): if not tr.transform_type in ["identity", "translation"]: self.tile_matching_transforms[n_symmetries] = tr n_symmetries = n_symmetries + 1 for tile in self.tiles[:self.n_tiles]: sm = Shape_Matcher(tile.shape) transforms = [sm.get_polygon_matches(self.tiles[i].shape) for i in self.shape_groups[tile.shape_group] if i < self.n_tiles] for tr in itertools.chain(*transforms): if not tr.transform_type in ["identity", "translation"]: self.tile_matching_transforms[n_symmetries] = tr n_symmetries = n_symmetries + 1 self.tile_matching_transforms = self._remove_duplicate_symmetries( self.tile_matching_transforms) return self.tile_matching_transforms
Assembles potential symmetries of the tiling from symmetries of the tileable.prototile and of the tileable.tiles. Removes any duplicates that result. 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 nudge_vertex(self,
vertex: Vertex,
dx: float,
dy: float)-
Expand source code
def nudge_vertex(self, vertex:Vertex, dx:float, dy:float): vertex.point = affine.translate(vertex.point, dx, dy)
def plot(self,
show_original_tiles: bool = True,
show_tile_centres: bool = False,
show_tile_vertex_labels: bool = False,
show_tile_edge_labels: bool = False,
show_vertex_ids: bool = False,
show_vertex_labels: bool = True,
show_edges: bool = True,
offset_edges: bool = True,
show_edge_labels: bool = False,
show_dual_tiles: bool = False) ‑> matplotlib.axes._axes.Axes-
Expand source code
def plot(self, show_original_tiles: bool = True, show_tile_centres: bool = False, show_tile_vertex_labels: bool = False, show_tile_edge_labels: bool = False, show_vertex_ids: bool = False, show_vertex_labels: bool = True, show_edges: bool = True, offset_edges: bool = True, show_edge_labels:bool = False, show_dual_tiles: bool = False) -> pyplot.Axes: fig = pyplot.figure(figsize = (10, 10)) ax = fig.add_axes(111) extent = gpd.GeoSeries([t.shape for t in self.tiles]).total_bounds dist = max([extent[2] - extent[0], extent[3] - extent[1]]) if show_original_tiles: self._get_tile_geoms().plot(column = "transitivity_class", cmap = "Set2", ax = ax, ec = "#444444", alpha = 0.2, lw = 0.75) if show_tile_centres: for i, tile in enumerate(self.tiles): ax.annotate(i, xy = (tile.centre.x, tile.centre.y), color = "b", fontsize = 10, ha = "center", va = "center") if show_vertex_labels: for v in self.points.values(): ax.annotate(v.ID if show_vertex_ids else v.label, xy = (v.point.x, v.point.y), color = "r", fontsize = 10, ha = "center", va = "center", bbox = dict(boxstyle="circle", lw=0, fc="#ffffff40")) if show_tile_vertex_labels: for tile in self.tiles: for posn, label in zip(tile.get_vertex_label_positions(), tile.vertex_labels): ax.annotate(label, xy = (posn.x, posn.y), fontsize = 8, ha = "center", va = "center", color = "b") if show_tile_edge_labels: for tile in self.tiles: for posn, label in zip(tile.get_edge_label_positions(), tile.edge_labels): ax.annotate(label, xy = (posn.x, posn.y), color = "b", ha = "center", va = "center", fontsize = 8) if show_edge_labels: if show_edges: edges = self._get_edge_geoms(dist / 100 if offset_edges else 0) edges.plot(ax = ax, color = "forestgreen", ls = "dashed", lw = 1) else: edges = [e.get_geometry() for e in self.edges.values()] for l, e in zip(edges, self.edges.values()): c = l.centroid ax.annotate(e.label, xy = (c.x, c.y), ha = "center", va = "center", fontsize = 10 if show_edge_labels else 7, color = "k") if show_dual_tiles: gpd.GeoSeries(self.dual_tiles).buffer( -dist / 400, join_style = 2, cap_style = 3).plot( ax = ax, fc = "k", alpha = 0.2) pyplot.axis("off") return ax
def plot_tiling_symmetries(self, **kwargs)
-
Expand source code
def plot_tiling_symmetries(self, **kwargs): n = len(self.tile_matching_transforms) nc = int(np.ceil(np.sqrt(n))) nr = int(np.ceil(n / nc)) gs = gpd.GeoSeries([t.shape for t in self.tiles]) gsb = gs[:self.n_tiles] fig = pyplot.figure(figsize = (12, 12 * nr / nc)) for i, tr in enumerate(self.tile_matching_transforms.values()): ax = fig.add_subplot(nr, nc, i + 1) gs.plot(ax = ax, fc = "b", alpha = 0.15, ec = "k", lw = 0.5) gsb.plot(ax = ax, fc = "#00000000", ec = "w", lw = 1, zorder = 2) gsm = gpd.GeoSeries([tr.apply(g) for g in gsb]) gsm.plot(ax = ax, fc = "r", alpha = 0.2, lw = 0, ec = "r") tr.draw(ax, **kwargs) pyplot.axis("off")
def push_vertex(self,
vertex: Vertex,
push_d: float) ‑> tuple[float]-
Expand source code
def push_vertex(self, vertex:Vertex, push_d:float) -> tuple[float]: neighbours = [self.points[v] for v in vertex.neighbours] dists = [vertex.point.distance(v.point) for v in neighbours] x, y = vertex.point.x, vertex.point.y unit_vectors = [((x - v.point.x) / d, (y - v.point.y) / d) for v, d in zip(neighbours, dists)] return (push_d * sum([xy[0] for xy in unit_vectors]), push_d * sum([xy[1] for xy in unit_vectors]))
def rotate_edge(self,
edge: Edge,
centre: str = 'A',
angle: float = 0) ‑> None-
Expand source code
def rotate_edge(self, edge:Edge, centre:str = "A", angle:float = 0) -> None: v0, v1 = edge.vertices[0], edge.vertices[1] ls = geom.LineString([v0.point, v1.point]) if v0.label == centre: c = v0.point elif v1.label == centre: c = v1.point else: c = ls.centroid ls = affine.rotate(ls, angle, origin = c) v0.point, v1.point = [geom.Point(c) for c in ls.coords]
def scale_edge(self,
edge: Edge,
sf: float = 1.0) ‑> None-
Expand source code
def scale_edge(self, edge:Edge, sf:float = 1.0) -> None: v0, v1 = edge.vertices[0], edge.vertices[1] ls = geom.LineString([v0.point, v1.point]) ls = affine.scale(ls, xfact = sf, yfact = sf, origin = ls.centroid) v0.point, v1.point = [geom.Point(c) for c in ls.coords]
def transform_geometry(self, new_topology: bool, apply_to_tiles: bool, selector: str, type: str, **kwargs) ‑> Topology
-
Expand source code
def transform_geometry(self, new_topology:bool, apply_to_tiles:bool, selector:str, type:str, **kwargs) -> "Topology": r"""Applies a specified transformation of elements in the Topology whose labels match the selector parameter, optionally applying the transform to update tile and optionally returning a new Topology object (or applying it to this one). 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". type (str): name of the type of transformation requested. Currently supported are `zigzag_edge`, `rotate_edge`, `push_vertex`, and `nudge_vertex`. Keyword arguments for each are documented in the corresponding methods. 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. """ print( f"""CAUTION: new Topology will probably not be correctly labelled. To build a correct Topology, extract the tileable attribute and rebuild Topology from that. """) topo = pickle.loads(pickle.dumps(self)) if new_topology else self transform_args = topo.get_kwargs(getattr(topo, type), **kwargs) if type == "zigzag_edge": for e in topo.edges.values(): if e.label in selector: topo.zigzag_edge(e, **transform_args) # --------------------------- elif type == "rotate_edge": for e in topo.edges.values(): if e.label in selector: topo.rotate_edge(e, **transform_args) # --------------------------- elif type == "scale_edge": for e in topo.edges.values(): if e.label in selector: topo.scale_edge(e, **transform_args) # --------------------------- elif type == "push_vertex": pushes = {} for v in topo.vertices_in_tiles(topo.tiles[:topo.n_tiles]): if v.label in selector: pushes[v.base_ID] = topo.push_vertex(v, **transform_args) for base_ID, (dx, dy) in pushes.items(): for v in [v for v in topo.points.values() if v.base_ID == base_ID]: v.point = affine.translate(v.point, dx, dy) # --------------------------- elif type == "nudge_vertex": for v in topo.points.values(): if v.label in selector: topo.nudge_vertex(v, **transform_args) if apply_to_tiles: for t in topo.tiles: t.set_corners_from_edges() topo.tileable.tiles.geometry = gpd.GeoSeries( [topo.tiles[i].shape for i in range(topo.n_tiles)]) topo.tileable.setup_regularised_prototile_from_tiles() return topo
Applies a specified transformation of elements in the Topology whose labels match the selector parameter, optionally applying the transform to update tile and optionally returning a new Topology object (or applying it to this one).
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". type
:str
- name of the type of transformation requested. Currently
supported are
zigzag_edge
,rotate_edge
,push_vertex
, andnudge_vertex
. Keyword arguments for each are documented in the corresponding methods.
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 vertices_in_tiles(self,
tiles: list[Tile]) ‑> list[Vertex]-
Expand source code
def vertices_in_tiles(self, tiles:list[Tile]) -> list[Vertex]: """Gets the vertices from self.points that are incident on the tiles in the supplied list. Args: tiles (list[Tile]): tiles whose vertices are required. Returns: list[Vertex]: the required vertices. """ vs = set() for tile in tiles: vs = vs.union(tile.get_corner_IDs()) return [self.points[v] for v in vs]
Gets the vertices from self.points that are incident on the tiles in the supplied list.
Args
tiles
:list[Tile]
- tiles whose vertices are required.
Returns
list[Vertex]
- the required vertices.
def zigzag_edge(self,
edge: Edge,
start: str = 'A',
sf: float = 1.0,
n: int = 2,
h: float = 0.5,
smoothness: int = 0)-
Expand source code
def zigzag_edge(self, edge:Edge, start:str = "A", sf:float = 1.0, n:int = 2, h:float = 0.5, smoothness:int = 0): """Applies a zigzag transformation to the 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 n (int, optional): number of zigs and zags in the edge. Defaults to 2. 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'. 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. """ v0, v1 = edge.vertices[0], edge.vertices[1] if n % 2 == 1 and v0.label != start: h = -h ls = tiling_utils.zigzag_between_points(v0.point, v1.point, n, h, smoothness) # remove current corners self.points = {k: v for k, v in self.points.items() if not k in edge.get_corner_IDs()[1:-1]} # add the new ones new_corners = [self.add_vertex(geom.Point(xy)) for xy in ls.coords] edge.corners = edge.vertices[:1] + new_corners + edge.vertices[-1:] if not edge.right_tile is None: edge.right_tile.set_corners_from_edges(False) if not edge.left_tile is None: edge.left_tile.set_corners_from_edges(False)
Applies a zigzag transformation to the 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 thisArgs
edge
:Edge
- Edge to transform
n
:int
, optional- number of zigs and zags in the edge. Defaults to 2.
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'.
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.