weavingspace.symmetry

Classes for working with symmetries.

  1"""Classes for working with symmetries."""
  2
  3from __future__ import annotations
  4
  5import inspect
  6from dataclasses import dataclass
  7from typing import TYPE_CHECKING
  8
  9import geopandas as gpd
 10import numpy as np
 11import shapely
 12import shapely.affinity as affine
 13import shapely.geometry as geom
 14from matplotlib import pyplot as plt
 15
 16from weavingspace import tiling_utils
 17
 18if TYPE_CHECKING:
 19  from collections.abc import Sequence
 20
 21
 22@dataclass(slots=True)
 23class KMP_Matcher:  # noqa: N801
 24  """Class to find matching subsequences in a sequence."""
 25
 26  sequence:Sequence
 27  """Iterable in which subsequences are to be found."""
 28
 29  def find_matches(
 30      self,
 31      pattern:Sequence[tuple[float,...]]) -> list[int]:
 32    """Find matches in supplied pattern using Knuth-Morris-Pratt algorithm.
 33
 34    See:
 35    https://en.wikipedia.org/wiki/Knuth-Morris-Pratt_algorithm which provides
 36    detailed pseudo-code on which this code is directly based. See also:
 37
 38    Knuth DE, JH Morris Jr, and VR Pratt. 1977. Fast pattern  matching in
 39    strings. SIAM Journal on Computing 6(2): 323-350. doi: 10.1137/0206024.
 40
 41    This implementation expects sequences of tuples of floats, although in
 42    principle any objects could be contained in the Sequence.
 43
 44    Args:
 45      pattern (Sequence[tuple[float,...]]): The sequence to match.
 46
 47    Returns:
 48      Sequence[int]: the index positions at which matches are found.
 49
 50    """
 51    j, k, = 0, 0
 52    finds = []
 53    table = self._get_table(pattern)
 54    while j < len(self.sequence):
 55      if self._equal_tuples(pattern[k], self.sequence[j]):
 56        j = j + 1
 57        k = k + 1
 58        if k == len(pattern):
 59          finds.append(j - k)
 60          k = table[k]
 61      else:
 62        k = table[k]
 63        if k < 0:
 64          j = j + 1
 65          k = k + 1
 66    return finds
 67
 68  def _get_table(
 69      self,
 70      pattern:Sequence) -> Sequence[int]:
 71    """Return 'offsets' table as required by self.find_matches.
 72
 73    Note that the terse variable names are those used in the KMP 1977 paper.
 74
 75    Args:
 76      pattern (Iterable): The pattern to set up the table for.
 77      equals (function, optional): A function to use to test for equality of
 78        elements in patterns. Defaults to _equal_tuples().
 79
 80    Returns:
 81      Iterable[int]: _description_
 82
 83    """
 84    pos = 1
 85    cnd = 0
 86    T = {0: -1}
 87    while pos < len(pattern):
 88      if np.allclose(pattern[pos], pattern[cnd]):
 89        T[pos] = T[cnd]
 90      else:
 91        T[pos] = cnd
 92        while cnd >= 0 and not self._equal_tuples(pattern[pos], pattern[cnd]):
 93          cnd = T[cnd]
 94      pos = pos + 1
 95      cnd = cnd + 1
 96    T[pos] = cnd
 97    return tuple(T.values())
 98
 99  def _equal_tuples(
100      self,
101      t1:Sequence[float],
102      t2:Sequence[float]) -> bool:
103    """Test for near equality of two iterables of floats.
104
105    We use numpy.allclose to avoid issues with floating point exact equality
106    tests. Wrapped to allow find_matches function to take alternative equality
107    tests as input.
108
109    Args:
110      t1 (Sequence[float]): First set of floats.
111      t2 (Sequence[float]): Second set of floats.
112
113    Returns:
114      bool: True if t1 == t2 for all corresponding items in each sequence.
115
116    """
117    return np.allclose(t1, t2, atol = tiling_utils.RESOLUTION * 10)
118
119  def _round_tuple(
120      self,
121      t:Sequence[float],
122      digits:int = 3) -> Sequence[float]:
123    """Round all floats in a sequence.
124
125    Args:
126      t (Sequence[float]): the floats to round.
127      digits (int): number of digits to round to.
128
129    Returns:
130      Sequence[float]: the rounded Sequence.
131
132    """
133    return tuple([np.round(x, digits) for x in t])
134
135
136@dataclass(slots=True, repr=False)
137class Transform:
138  """Class to store details of a transform and draw it."""
139
140  transform_type:str
141  """Type of transform 'rotation', 'reflection', 'translation' or 'identity'."""
142  angle:float
143  """Angle of rotation (degrees)."""
144  centre:geom.Point
145  """Centre of the transformation."""
146  translation:tuple[float,...]
147  """X and Y coordinates shifts of a translation transform. A glide reflection
148  may also include this."""
149  transform:tuple[float,...]
150  """Six element tuple for the transform in shapely.transform format. See
151  https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
152  and methods in `weavingspace.tiling_utils`."""
153
154  def __str__(self) -> str:
155    if self.transform_type == "rotation":
156      return (
157        f"{self.transform_type:<11}{self.angle:>6.1f}° "
158        f"POINT ({self.centre.x:6.1f} {self.centre.y:6.1f}) "
159        f"{tuple([float(np.round(x, 3)) for x in self.transform])}"
160      )
161    if self.transform_type == "translation":
162      return (
163        f"{self.transform_type:<11}{'':>14}"
164        f"({self.translation[0]:>6.1f},{self.translation[0]:>6.1f}) "
165        f"{tuple([float(np.round(x, 3)) for x in self.transform])}"
166      )
167    if self.transform_type == "reflection":
168      return (
169        f"{self.transform_type:<11}{self.angle:>6.1f}° "
170        f"POINT ({self.centre.x:6.1f} {self.centre.y:6.1f}) "
171        f"{tuple([float(np.round(x, 3)) for x in self.transform])}"
172      )
173    return (
174      f"{self.transform_type:<11}{'':>30}"
175      f"{tuple([float(np.round(x, 3)) for x in self.transform])}"
176    )
177
178
179  def __repr__(self) -> str:
180    return str(self)
181
182
183  def apply(
184      self,
185      geometry:shapely.Geometry) -> shapely.Geometry:
186    """Apply this transform to supplied shapely geometry and return result.
187
188    Args:
189      geometry (shapely.Geometry): a shapely geometry to transform.
190
191    Returns:
192      shapely.Geometry: the resulting transformed shapely geometry.
193
194    """
195    return affine.affine_transform(geometry, self.transform)
196
197
198  def draw(
199      self,
200      ax:plt.Axes,
201      **kwargs:str,
202    ) -> plt.Axes:
203    """Draw this transform on the supplied axes.
204
205    Arguments specific to each transform type are supplied as **kwargs and are
206    documented in `draw_rotation`, `draw_reflection`, and `draw_translation`.
207
208    Args:
209      ax (pyplot.axes): the axes on which to draw the transform.
210      kwargs: passed on to specific draw method.
211
212    Returns:
213      pyplot.Axes: the axes with the rendering of this transform added.
214
215    """
216    match self.transform_type:
217      case "rotation":
218        args = inspect.signature(self.draw_rotation).parameters
219        rotn_args = {k: kwargs.pop(k) for k in dict(kwargs) if k in args}
220        return self.draw_rotation(ax = ax, **rotn_args)
221      case "reflection":
222        args = inspect.signature(self.draw_reflection).parameters
223        refn_args = {k: kwargs.pop(k) for k in dict(kwargs) if k in args}
224        return self.draw_reflection(ax = ax, **refn_args)
225      case "translation":
226        args = inspect.signature(self.draw_translation).parameters
227        trans_args = {k: kwargs.pop(k) for k in dict(kwargs) if k in args}
228        return self.draw_translation(ax = ax, **trans_args)
229      case "identity":
230        w = ax.get_window_extent().width
231        return ax.set_title(f"{self.transform_type}", fontsize = w / 20)
232
233
234  def draw_rotation(
235      self,
236      ax:plt.Axes,
237      radius:float = 200,
238      add_title:bool = True,
239    ) -> plt.Axes:
240    """Draw a rotation transform on the supplied Axes.
241
242    Args:
243      ax (pyplot.Axes): axes on which to draw the representation.
244      radius (float): radius in object units to draw arcs representing the
245        angle. Defaults to 200.
246      add_title (bool): whether or not to add a title to the drawing.
247        Defaults to True.
248
249    Returns:
250      the Axes with the rendering of this transform added.
251
252    """
253    x, y = self.centre.x, self.centre.y
254    axis = geom.LineString([(x, y), (x + radius * 1.25, y)])
255    arc = geom.LineString([
256      geom.Point(x + radius * np.cos(np.radians(a)),
257                 y + radius * np.sin(np.radians(a)))
258      for a in np.linspace(0, self.angle, 50)])
259    gpd.GeoSeries(self.centre).plot(
260      ax = ax, color = "k", markersize = 4, zorder = 5)
261    gpd.GeoSeries([axis, arc]).plot(ax = ax, color = "k", lw = .5)
262    if add_title:
263      w = ax.get_window_extent().width
264      ax.set_title(
265        f"{self.transform_type} {round(self.angle, 2)}", fontsize = w / 20)
266    return ax
267
268
269  def draw_reflection(
270      self,
271      ax:plt.Axes,
272      w:float = 5,
273      mirror_length:float = 100,
274      add_title:bool = True,
275    ) -> plt.Axes:
276    """Draw a reflection transform on the supplied Axes.
277
278    Args:
279      ax (pyplot.Axes): axes on which to draw the representation.
280      w (float): linewidth for the mirror line. Defaults to 5.
281      mirror_length (float): length of the mirror line in units of the
282        objects being reflected. Defaults to 100.
283      add_title (bool): whether or not to add a title to the drawing.
284        Defaults to True.
285
286    Returns:
287      the Axes with the rendering of this transform added.
288
289    """
290    x, y = self.centre.x, self.centre.y
291    dx, dy = self.translation
292    r = np.sqrt(self.translation[0] ** 2 + self.translation[1] ** 2)
293    mirror = geom.LineString([
294      (x - mirror_length / 2 * np.cos(np.radians(self.angle)),
295       y - mirror_length / 2 * np.sin(np.radians(self.angle))),
296      (x + mirror_length / 2 * np.cos(np.radians(self.angle)),
297       y + mirror_length / 2 * np.sin(np.radians(self.angle)))])
298    gpd.GeoSeries([mirror]).plot(
299      ax = ax, color = "k", lw = 1, ls = "dashdot", zorder = 5)
300    no_slide = np.isclose(r, 0, rtol = 1e-6, atol = 1e-6)
301    if not no_slide:
302      ax.arrow(
303        x - dx / 2, y - dy / 2, dx, dy, length_includes_head = True,
304        width = w, fc = "k", ec = None, head_width = w * 6, zorder = 5)
305    if add_title:
306      w = ax.get_window_extent().width
307      ax.set_title(
308        f"{self.transform_type} {round(self.angle, 2)}", fontsize = w / 20)
309    return ax
310
311
312  def draw_translation(
313      self,
314      ax:plt.Axes,
315      c:geom.Point,
316      w:float = 5,
317      add_title:bool = True,
318    ) -> plt.Axes:
319    """Draw a translation transform on the supplied Axes.
320
321    Args:
322      ax (pyplot.Axes): axes on which to draw the representation.
323      c (geom.Point): an origin point in the coordinate space of the objects
324        from which to start the arrow representing the translation.
325      w (float): linewidth for the arrow. Defaults to 5.
326      add_title (bool): whether or not to add a title to the drawing.
327        Defaults to True.
328
329    Returns:
330      the Axes with the rendering of this transform added.
331
332    """
333    gpd.GeoSeries([c]).plot(ax = ax, color = "k")
334    ax.arrow(c.x, c.y, self.translation[0], self.translation[1],
335             length_includes_head = True, lw = 0.5, width = w,
336             fc = "k", ec = None, head_width = w * 6, zorder = 5)
337    if add_title:
338      w = ax.get_window_extent().width
339      ax.set_title(
340        (f"{self.transform_type} "
341         f"({self.translation[0]:.1f}, {self.translation[1]:.1f})"),
342        fontsize = w / 20)
343    return ax
344
345
346@dataclass(slots=True, init=False)
347class Symmetries:
348  """Class to identify and store symmetries of a shapely.Polygon."""
349
350  polygon:geom.Polygon
351  """Polygon from which these symmetries are derived."""
352  matcher:KMP_Matcher
353  """the subsequence matcher used in symmetry detection."""
354  n:int
355  """number of vertices of the polygon."""
356  poly_code:list[tuple[float,...]]
357  """the encoding of the polygon as sequence of length, angle pairs."""
358  poly_code_r:list[tuple[float,...]]
359  """the reversed encoding used to detect reflection symmetries."""
360  rotation_shifts:list[int]
361  """list of number of 2pi/n rotation symmetries."""
362  reflection_shifts:list[int]
363  """list of pi/n relection angle symmetries."""
364  symmetries:list[Transform]
365  """list of Transform objects with more complete information."""
366  symmetry_group:str
367  """the code denoting the symmetry group"""
368
369  def __init__(self, polygon:geom.Polygon) -> None:
370    """Construct a Symmetries instance."""
371    self.polygon = polygon
372    self.n = shapely.count_coordinates(self.polygon) - 1
373    self.poly_code = self._get_polygon_code(self.polygon)
374    self.poly_code_r = self._get_polygon_code(self.polygon, mirrored = True)
375    self.matcher = KMP_Matcher(self.poly_code + self.poly_code[:-1])
376    self.symmetries = self.get_symmetries()
377    self.symmetry_group = self.get_symmetry_group_code()
378
379
380  def _get_polygon_code(
381      self,
382      polygon:geom.Polygon,
383      mirrored:bool = False,
384    ) -> list[tuple[float,...]]:
385    r"""Return list of length-angle pairs to encode a polygon shape.
386
387    This encoding is unique up to scale. The alignment of lengths and angles is
388    important and symmetry detection is sensitively dependent on it...
389
390    The forward (i.e. unmirrored) polygon pairs are of edge lengths and the
391    angle between each edge and its successor going CW around the polygon.
392
393           A[i] ----L[i]---- A (i+1)
394          /                   \
395         /                     \
396
397    i.e. edge i is between angles i and i + 1, and angle i is between edge
398    i-1 and i. The mirrored encoding pairs length[i] with angle[i+1].
399
400    The mirrored encoding pairs matched indexes of lengths and angles in the
401    original polygon. This means the angle is still the one between and edge
402    and its successor but proceeding CCW around the polygon.
403
404    See in particular for clarification:
405
406      Eades P. 1988. Symmetry Finding Algorithms. In Machine Intelligence and
407      Pattern Recognition, ed. GT Toussaint, 6:41-51. Computational Morphology.
408      North-Holland. doi: 10.1016/B978-0-444-70467-2.50009-6.
409
410    Args:
411      polygon (geom.Polygon): the polygon to encode
412      mirrored (bool, optional): if true encoding will be in CCW order.
413        Defaults to False.
414
415    Returns:
416      list[tuple[float]]: a list of side-length angle pairs.
417
418    """
419    # get edge lengths and angles
420    lengths = tiling_utils.get_side_lengths(polygon)
421    raw_angles = tiling_utils.get_interior_angles(polygon)
422    if mirrored:
423      lengths_r = lengths[::-1]
424      angles_r = raw_angles[::-1]
425      return list(zip(lengths_r, angles_r, strict = True))
426    angles = raw_angles[1:] + raw_angles[:1]
427    return list(zip(lengths, angles, strict = True))
428
429
430  def get_symmetries(self) -> list[Transform]:
431    """Find rotation and reflection symmetries of a polygon.
432
433    The work is delegated to `get_rotations()` and `get_reflections()`.
434
435    Based on
436
437      Eades P. 1988. Symmetry Finding Algorithms. In Machine Intelligence and
438      Pattern Recognition, ed. GT Toussaint, 6:41-51. Computational Morphology.
439      North-Holland. doi: 10.1016/B978-0-444-70467-2.50009-6.
440
441    and also
442
443      Wolter JD, TC Woo, and RA Volz. 1985. Optimal algorithms for symmetry
444      detection in two and three dimensions. The Visual Computer 1(1): 37-48.
445      doi: 10.1007/BF01901268.
446
447    Details in these papers are not unambiguous. This implementation was
448    developed based on them, but with some trial and error to get the index
449    offsets and (especially) retrieval of the reflection axes angles to work.
450
451    Returns:
452      list[Transform]: a list of Transform objects representing the polygon
453        symmetries.
454
455    """
456    return self.get_rotations() + self.get_reflections()
457
458
459  def get_rotations(
460      self,
461      offsets:None|list[int] = None,
462    ) -> list[Transform]:
463    """Get the rotations associated with this collection of symmetries.
464
465    Returns:
466      list[Transform]: A list of the rotation symmetries associated with this
467        Symmetries instance's polygon.
468
469    """
470    if offsets is None:
471      self.rotation_shifts = self.matcher.find_matches(self.poly_code)
472      offsets = self.rotation_shifts
473    c = self.polygon.centroid
474    rot_angles = [np.round(i * 360 / self.n, 6) for i in offsets]
475    rotation_transforms = [tuple(
476      np.round(tiling_utils.get_rotation_transform(a, (c.x, c.y)), 6))
477      for a in rot_angles]
478    return [
479      Transform("rotation", angle, c, (0, 0), transform) for
480      transform, angle in zip(rotation_transforms, rot_angles, strict = True)]
481
482
483  def get_reflections(
484      self,
485      offsets:None|list[int] = None,
486    ) -> list[Transform]:
487    """Get the reflections associated with this collection of symmetries.
488
489    Args:
490      offsets (list[int]): list of integer offsets i where i / n * 360 where n
491        is the number of sides of this Symmetries instance's polygons is the
492        angle of the mirror in which a reflection symmetry has been identified.
493
494    Returns:
495      list[Transform]: A list of the reflection symmetries associated with this
496        Symmetries instance's polygon.
497
498    """
499    # collate all possible reflection axes - ordering of these is important
500    # for correct picking - so don't mess with this code!
501    bearings = tiling_utils.get_side_bearings(self.polygon)
502    reflection_axes = []
503    interior_angles = tiling_utils.get_interior_angles(self.polygon)
504    if offsets is None:
505      self.reflection_shifts = self.matcher.find_matches(self.poly_code_r)
506      offsets = self.reflection_shifts
507    for i in range(self.n):
508      # the angle bisectors
509      reflection_axes.append(bearings[i] - interior_angles[i] / 2)
510      # the edge_perpendicular bisectors
511      reflection_axes.append(bearings[i] - 90)  # normal to the edge
512    # pick out those where matches occurred using the offsets
513    ref_angles = [np.round(reflection_axes[i], 6) for i in offsets]
514    c = self.polygon.centroid
515    reflection_transforms = [tuple(
516      np.round(tiling_utils.get_reflection_transform(angle, (c.x, c.y)), 6))
517      for angle in ref_angles]
518    return [
519      Transform("reflection", angle, c, (0, 0), transform) for
520      transform, angle in zip(reflection_transforms, ref_angles, strict = True)]
521
522  def get_symmetry_group_code(self) -> str:
523    """Return symmetry group code for this instance's polygon.
524
525    See https://en.wikipedia.org/wiki/Symmetry_group#Two_dimensions.
526
527    Returns:
528        str: code such as C2 or D4 for cyclic or dihedral symmetry groups.
529
530    """
531    if len(self.reflection_shifts) == 0:
532      # no reflection symmetries, so it is a cyclic group Cn
533      return f"C{len(self.rotation_shifts)}"
534    # must be the dihedral group Dn
535    return f"D{len(self.rotation_shifts)}"
536
537
538  def get_corner_offset(
539      self,
540      poly2:geom.Polygon) -> int|None:
541    """Find offset between corners of this polygon and another one.
542
543    The offset is determined under whatever transformation will match one on
544    to the other. Used by the `Topology` class. The offset is expressed as the
545    number of corners clockwise by which the supplied polygon is offset from
546    this one.
547
548    Args:
549      poly2 (geom.Polygon): polygon for which the offset is desired.
550
551    Returns:
552      int|None: integer number of corners clockwise by which the polygon
553        is offset from this one, or None if they don't match.
554
555    """
556    s2 = Symmetries(poly2)
557    matches = self.matcher.find_matches(s2.poly_code)
558    if len(matches) > 0:
559      return matches[0]
560    matches = self.matcher.find_matches(s2.poly_code_r)
561    if len(matches) > 0:
562      return -matches[0]
563    return None
564
565
566@dataclass(slots=True, init=False)
567class ShapeMatcher:
568  """Class to manage finding transforms that map one polygon on to another.
569
570  This was previously handled 'internally' by an earlier version of the
571  Symmetries code, but that quickly became ungainly and difficult to maintain.
572  This approach makes for cleaner code, and does the additional work of finding
573  e.g. centres of rotations (which may lie outside either or both polygons).
574  """
575
576  shape:geom.Polygon
577  """The target polygon on to which matching transforms are required."""
578  s1:Symmetries
579  """The Symmetries of the target polygon."""
580
581  def __init__(  # noqa: D107
582      self,
583      shape: geom.Polygon) -> None:
584    self.shape = shape
585    self.s1 = Symmetries(shape)
586
587
588  def get_polygon_matches(
589      self,
590      shape2:geom.Polygon) -> list[Transform]|None:
591    """Return all transforms that match shape2 onto this instance's polygon.
592
593    Args:
594      shape2 (geom.Polygon): polygon for which matching transforms are
595        requested.
596
597    Returns:
598      list[Transform]|None: a list of Transforms that will match shape2
599        onto this instance's shape. None is returned if no matching transforms
600        are found.
601
602    """
603    s2 = Symmetries(shape2)
604    if self.s1.symmetry_group != s2.symmetry_group:
605      return None
606    match_rot, rots = self._get_rotation_matches(s2)
607    match_ref, refs = self._get_reflection_matches(s2)
608    if match_rot and match_ref:
609      return rots + refs
610    if match_rot:
611      return rots
612    if match_ref:
613      return refs
614    return None
615
616
617  def _get_rotation_matches(
618      self,
619      s2:Symmetries) -> tuple[bool,list[Transform]]:
620    """Get rotations to match s2's polygon onto this instance's polygon.
621
622    Args:
623      s2 (Symmetries): Symmetries associated with the polygon of interest.
624
625    Returns:
626      tuple[bool,list[Transform]]: boolean is True if there are any matches
627        when matching transforms will be included in the associated list. If
628        there are no matches (False, []) will be returned.
629
630    """
631    # need this locally to check for equality of all members of a list
632    def all_equal(lst:list[float]) -> bool:
633      return all(np.isclose(lst[0], x, 1e-3, 1e-3) for x in lst)
634
635    matches = self.s1.matcher.find_matches(s2.poly_code)
636    if len(matches) == 0:
637      return False, None
638    transforms = []
639    # get lists of polygon corners aligned correctly to measure the angle
640    p1_corners = tiling_utils.get_corners(self.shape, repeat_first = False)
641    p2_corners = tiling_utils.get_corners(s2.polygon, repeat_first = False)
642    corner_distances = self._get_corner_distances_between_polys(
643      p1_corners, p2_corners, matches)
644    equalities = [all_equal(d) for d in corner_distances]
645    if all(equalities):
646      # the two shapes are identical and in the same spot
647      # so rotations symmetries are those of the target shape
648      transforms.extend(self.s1.get_rotations(matches))
649      return True, transforms
650    if any(equalities):
651      # the one where all are equal is a translation so we'll skip it below
652      # and add it here as a translation transform
653      m = equalities.index(True)
654      del matches[m]
655      dx, dy = (p1_corners[0].x - p2_corners[m].x,
656                p1_corners[0].y - p2_corners[m].y)
657      transforms.append(
658        Transform("translation", None, None, (dx, dy), (1, 0, 0, 1, dx, dy)))
659    for m in matches:
660      a, c = self._get_angle_between_polygons(p1_corners, p2_corners, m)
661      if a is None:
662        continue
663      transforms.append(Transform("rotation", a, c, (0, 0),
664        tiling_utils.get_rotation_transform(a, (c.x, c.y))))
665    return True, transforms
666
667
668  def _get_corner_distances_between_polys(
669      self,
670      corners1:list[geom.Point],
671      corners2:list[geom.Point],
672      offsets:list[int],
673    ) -> list[list[float]]:
674    return [
675      [p1.distance(p2) for p1, p2 in
676       zip(corners1, corners2[i:] + corners2[:i], strict = True)]
677      for i in offsets]
678
679
680  def _get_angle_between_polygons(
681      self,
682      corners1:list[geom.Point],
683      corners2:list[geom.Point],
684      offset:int,
685    ) -> tuple[None,...]|tuple[str,tuple[float,...]]|tuple[float,geom.Point]:
686    """Return angle by which lists of corners are rotationally offset.
687
688    If lists are at the same orientation then the translation required to match
689    them (if any) will be returned.
690
691    Args:
692      corners1 (list[geom.Point]): list of corners of the first polygon.
693      corners2 (list[geom.Point]): list of corners of the second polygon.
694      offset (int): number of clockwise steps through the corners by which
695        corners2 are offset from corners1 as previously determined.
696
697    Returns:
698      tuple[float,geom.Point]|tuple[str,tuple[float]]: one of
699      ('identity' (0, 0)), ('translation', (x, y)), or (angle, geom.Point),
700        where the last of these is an angle and a centre of rotation.
701
702    """
703    corners2 = corners2[offset:] + corners2[:offset]
704    dists = [
705      p1.distance(p2) for p1, p2 in zip(corners1, corners2, strict = True)]
706    if all(np.isclose(dists[0], d, atol = 1e-3, rtol = 1e-3) for d in dists):
707      # polygons already aligned similarly so we just need to find translation
708      if np.isclose(dists[0], 0, atol = 1e-3, rtol  = 1e-3):
709        return "identity", (0, 0)
710      if offset == 0:
711        return "translation", (corners1[0].x - corners2[0].x,
712                               corners1[0].y - corners2[0].y)
713    # Polygons not lined up. Order them by distances between them
714    ordered_dists = sorted([(i, d) for i, d in enumerate(dists)],
715                           key = lambda x: x[1], reverse = True)
716    # the two furthest will give the most reliable numbers
717    a, b = [i for i, d in ordered_dists[:2]]
718    p_1a, p_1b = corners1[a], corners1[b]
719    p_2a, p_2b = corners2[a], corners2[b]
720    # get the perpendiculars of the lines joining A and B in each polygon
721    perp_1a_2a = tiling_utils.get_straight_line(
722      p_1a, p_2a, perpendicular = True)
723    perp_1b_2b = tiling_utils.get_straight_line(
724      p_1b, p_2b, perpendicular = True)
725    # and intersect them
726    centre = tiling_utils.get_intersection(perp_1a_2a, perp_1b_2b)
727    if centre is None:
728      return None, None
729    angle = -tiling_utils.get_inner_angle(p_1a, centre, p_2a)
730    if angle < -179:
731      angle = angle + 360
732    elif angle > 180:
733      angle = angle - 360
734    return angle, centre
735
736
737  def _get_reflection_matches(
738      self,
739      s2:Symmetries,
740    ) -> tuple[bool,list[Transform]|None]:
741    """Get reflections that match s2's polygon to this instance's polygon.
742
743    Args:
744      s2 (Symmetries): Symmetries associated with the polygon of interest.
745
746    Returns:
747      tuple[bool,list[Transform]]: boolean is True if there are any matches
748        when matching transforms will be included in the associated list. If
749        there are no matches (False, []) will be returned.
750
751    """
752    matches = self.s1.matcher.find_matches(s2.poly_code_r)
753    if len(matches) == 0:
754      return False, None
755    # get mean centroid - all reflection matches will pass through this
756    c1 = self.s1.polygon.centroid
757    c2 = s2.polygon.centroid
758    c = ((c1.x + c2.x) / 2, (c1.y + c2.y) / 2)
759    # get each polygon's reflection symmetries
760    reflections1 = self.s1.get_reflections(matches)
761    reflections2 = s2.get_reflections(matches)
762    # their mean angle will be a line of reflection through c
763    angles = [(ref1.angle + ref2.angle) / 2
764              for ref1, ref2 in zip(reflections1, reflections2, strict = True)]
765    transforms = [tiling_utils.get_reflection_transform(angle, c)
766                  for angle in angles]
767    # there may also be a 'glide' component additional to the reflection
768    # to find it reflect the centre of polygon 2 by each transform and
769    # then find translation necessary to map it onto the centre of polygon 1
770    ctrs2r = [affine.affine_transform(c2, transform)
771              for transform in transforms]
772    dxdys = [(c1.x - ctr2r.x, c1.y - ctr2r.y) for ctr2r in ctrs2r]
773    return (True,
774      [Transform(
775        "reflection", angle, geom.Point(c), dxdy,
776        tiling_utils.combine_transforms([
777          tiling_utils.get_reflection_transform(angle, c),
778          tiling_utils.get_translation_transform(dxdy[0], dxdy[1])]))
779          for angle, dxdy in zip(angles, dxdys, strict = True)])
@dataclass(slots=True)
class KMP_Matcher:
 23@dataclass(slots=True)
 24class KMP_Matcher:  # noqa: N801
 25  """Class to find matching subsequences in a sequence."""
 26
 27  sequence:Sequence
 28  """Iterable in which subsequences are to be found."""
 29
 30  def find_matches(
 31      self,
 32      pattern:Sequence[tuple[float,...]]) -> list[int]:
 33    """Find matches in supplied pattern using Knuth-Morris-Pratt algorithm.
 34
 35    See:
 36    https://en.wikipedia.org/wiki/Knuth-Morris-Pratt_algorithm which provides
 37    detailed pseudo-code on which this code is directly based. See also:
 38
 39    Knuth DE, JH Morris Jr, and VR Pratt. 1977. Fast pattern  matching in
 40    strings. SIAM Journal on Computing 6(2): 323-350. doi: 10.1137/0206024.
 41
 42    This implementation expects sequences of tuples of floats, although in
 43    principle any objects could be contained in the Sequence.
 44
 45    Args:
 46      pattern (Sequence[tuple[float,...]]): The sequence to match.
 47
 48    Returns:
 49      Sequence[int]: the index positions at which matches are found.
 50
 51    """
 52    j, k, = 0, 0
 53    finds = []
 54    table = self._get_table(pattern)
 55    while j < len(self.sequence):
 56      if self._equal_tuples(pattern[k], self.sequence[j]):
 57        j = j + 1
 58        k = k + 1
 59        if k == len(pattern):
 60          finds.append(j - k)
 61          k = table[k]
 62      else:
 63        k = table[k]
 64        if k < 0:
 65          j = j + 1
 66          k = k + 1
 67    return finds
 68
 69  def _get_table(
 70      self,
 71      pattern:Sequence) -> Sequence[int]:
 72    """Return 'offsets' table as required by self.find_matches.
 73
 74    Note that the terse variable names are those used in the KMP 1977 paper.
 75
 76    Args:
 77      pattern (Iterable): The pattern to set up the table for.
 78      equals (function, optional): A function to use to test for equality of
 79        elements in patterns. Defaults to _equal_tuples().
 80
 81    Returns:
 82      Iterable[int]: _description_
 83
 84    """
 85    pos = 1
 86    cnd = 0
 87    T = {0: -1}
 88    while pos < len(pattern):
 89      if np.allclose(pattern[pos], pattern[cnd]):
 90        T[pos] = T[cnd]
 91      else:
 92        T[pos] = cnd
 93        while cnd >= 0 and not self._equal_tuples(pattern[pos], pattern[cnd]):
 94          cnd = T[cnd]
 95      pos = pos + 1
 96      cnd = cnd + 1
 97    T[pos] = cnd
 98    return tuple(T.values())
 99
100  def _equal_tuples(
101      self,
102      t1:Sequence[float],
103      t2:Sequence[float]) -> bool:
104    """Test for near equality of two iterables of floats.
105
106    We use numpy.allclose to avoid issues with floating point exact equality
107    tests. Wrapped to allow find_matches function to take alternative equality
108    tests as input.
109
110    Args:
111      t1 (Sequence[float]): First set of floats.
112      t2 (Sequence[float]): Second set of floats.
113
114    Returns:
115      bool: True if t1 == t2 for all corresponding items in each sequence.
116
117    """
118    return np.allclose(t1, t2, atol = tiling_utils.RESOLUTION * 10)
119
120  def _round_tuple(
121      self,
122      t:Sequence[float],
123      digits:int = 3) -> Sequence[float]:
124    """Round all floats in a sequence.
125
126    Args:
127      t (Sequence[float]): the floats to round.
128      digits (int): number of digits to round to.
129
130    Returns:
131      Sequence[float]: the rounded Sequence.
132
133    """
134    return tuple([np.round(x, digits) for x in t])

Class to find matching subsequences in a sequence.

KMP_Matcher(sequence: Sequence)
sequence: Sequence

Iterable in which subsequences are to be found.

def find_matches(self, pattern: Sequence[tuple[float, ...]]) -> list[int]:
30  def find_matches(
31      self,
32      pattern:Sequence[tuple[float,...]]) -> list[int]:
33    """Find matches in supplied pattern using Knuth-Morris-Pratt algorithm.
34
35    See:
36    https://en.wikipedia.org/wiki/Knuth-Morris-Pratt_algorithm which provides
37    detailed pseudo-code on which this code is directly based. See also:
38
39    Knuth DE, JH Morris Jr, and VR Pratt. 1977. Fast pattern  matching in
40    strings. SIAM Journal on Computing 6(2): 323-350. doi: 10.1137/0206024.
41
42    This implementation expects sequences of tuples of floats, although in
43    principle any objects could be contained in the Sequence.
44
45    Args:
46      pattern (Sequence[tuple[float,...]]): The sequence to match.
47
48    Returns:
49      Sequence[int]: the index positions at which matches are found.
50
51    """
52    j, k, = 0, 0
53    finds = []
54    table = self._get_table(pattern)
55    while j < len(self.sequence):
56      if self._equal_tuples(pattern[k], self.sequence[j]):
57        j = j + 1
58        k = k + 1
59        if k == len(pattern):
60          finds.append(j - k)
61          k = table[k]
62      else:
63        k = table[k]
64        if k < 0:
65          j = j + 1
66          k = k + 1
67    return finds

Find matches in supplied pattern using Knuth-Morris-Pratt algorithm.

See: https://en.wikipedia.org/wiki/Knuth-Morris-Pratt_algorithm which provides detailed pseudo-code on which this code is directly based. See also:

Knuth DE, JH Morris Jr, and VR Pratt. 1977. Fast pattern matching in strings. SIAM Journal on Computing 6(2): 323-350. doi: 10.1137/0206024.

This implementation expects sequences of tuples of floats, although in principle any objects could be contained in the Sequence.

Args: pattern (Sequence[tuple[float,...]]): The sequence to match.

Returns: Sequence[int]: the index positions at which matches are found.

@dataclass(slots=True, repr=False)
class Transform:
137@dataclass(slots=True, repr=False)
138class Transform:
139  """Class to store details of a transform and draw it."""
140
141  transform_type:str
142  """Type of transform 'rotation', 'reflection', 'translation' or 'identity'."""
143  angle:float
144  """Angle of rotation (degrees)."""
145  centre:geom.Point
146  """Centre of the transformation."""
147  translation:tuple[float,...]
148  """X and Y coordinates shifts of a translation transform. A glide reflection
149  may also include this."""
150  transform:tuple[float,...]
151  """Six element tuple for the transform in shapely.transform format. See
152  https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
153  and methods in `weavingspace.tiling_utils`."""
154
155  def __str__(self) -> str:
156    if self.transform_type == "rotation":
157      return (
158        f"{self.transform_type:<11}{self.angle:>6.1f}° "
159        f"POINT ({self.centre.x:6.1f} {self.centre.y:6.1f}) "
160        f"{tuple([float(np.round(x, 3)) for x in self.transform])}"
161      )
162    if self.transform_type == "translation":
163      return (
164        f"{self.transform_type:<11}{'':>14}"
165        f"({self.translation[0]:>6.1f},{self.translation[0]:>6.1f}) "
166        f"{tuple([float(np.round(x, 3)) for x in self.transform])}"
167      )
168    if self.transform_type == "reflection":
169      return (
170        f"{self.transform_type:<11}{self.angle:>6.1f}° "
171        f"POINT ({self.centre.x:6.1f} {self.centre.y:6.1f}) "
172        f"{tuple([float(np.round(x, 3)) for x in self.transform])}"
173      )
174    return (
175      f"{self.transform_type:<11}{'':>30}"
176      f"{tuple([float(np.round(x, 3)) for x in self.transform])}"
177    )
178
179
180  def __repr__(self) -> str:
181    return str(self)
182
183
184  def apply(
185      self,
186      geometry:shapely.Geometry) -> shapely.Geometry:
187    """Apply this transform to supplied shapely geometry and return result.
188
189    Args:
190      geometry (shapely.Geometry): a shapely geometry to transform.
191
192    Returns:
193      shapely.Geometry: the resulting transformed shapely geometry.
194
195    """
196    return affine.affine_transform(geometry, self.transform)
197
198
199  def draw(
200      self,
201      ax:plt.Axes,
202      **kwargs:str,
203    ) -> plt.Axes:
204    """Draw this transform on the supplied axes.
205
206    Arguments specific to each transform type are supplied as **kwargs and are
207    documented in `draw_rotation`, `draw_reflection`, and `draw_translation`.
208
209    Args:
210      ax (pyplot.axes): the axes on which to draw the transform.
211      kwargs: passed on to specific draw method.
212
213    Returns:
214      pyplot.Axes: the axes with the rendering of this transform added.
215
216    """
217    match self.transform_type:
218      case "rotation":
219        args = inspect.signature(self.draw_rotation).parameters
220        rotn_args = {k: kwargs.pop(k) for k in dict(kwargs) if k in args}
221        return self.draw_rotation(ax = ax, **rotn_args)
222      case "reflection":
223        args = inspect.signature(self.draw_reflection).parameters
224        refn_args = {k: kwargs.pop(k) for k in dict(kwargs) if k in args}
225        return self.draw_reflection(ax = ax, **refn_args)
226      case "translation":
227        args = inspect.signature(self.draw_translation).parameters
228        trans_args = {k: kwargs.pop(k) for k in dict(kwargs) if k in args}
229        return self.draw_translation(ax = ax, **trans_args)
230      case "identity":
231        w = ax.get_window_extent().width
232        return ax.set_title(f"{self.transform_type}", fontsize = w / 20)
233
234
235  def draw_rotation(
236      self,
237      ax:plt.Axes,
238      radius:float = 200,
239      add_title:bool = True,
240    ) -> plt.Axes:
241    """Draw a rotation transform on the supplied Axes.
242
243    Args:
244      ax (pyplot.Axes): axes on which to draw the representation.
245      radius (float): radius in object units to draw arcs representing the
246        angle. Defaults to 200.
247      add_title (bool): whether or not to add a title to the drawing.
248        Defaults to True.
249
250    Returns:
251      the Axes with the rendering of this transform added.
252
253    """
254    x, y = self.centre.x, self.centre.y
255    axis = geom.LineString([(x, y), (x + radius * 1.25, y)])
256    arc = geom.LineString([
257      geom.Point(x + radius * np.cos(np.radians(a)),
258                 y + radius * np.sin(np.radians(a)))
259      for a in np.linspace(0, self.angle, 50)])
260    gpd.GeoSeries(self.centre).plot(
261      ax = ax, color = "k", markersize = 4, zorder = 5)
262    gpd.GeoSeries([axis, arc]).plot(ax = ax, color = "k", lw = .5)
263    if add_title:
264      w = ax.get_window_extent().width
265      ax.set_title(
266        f"{self.transform_type} {round(self.angle, 2)}", fontsize = w / 20)
267    return ax
268
269
270  def draw_reflection(
271      self,
272      ax:plt.Axes,
273      w:float = 5,
274      mirror_length:float = 100,
275      add_title:bool = True,
276    ) -> plt.Axes:
277    """Draw a reflection transform on the supplied Axes.
278
279    Args:
280      ax (pyplot.Axes): axes on which to draw the representation.
281      w (float): linewidth for the mirror line. Defaults to 5.
282      mirror_length (float): length of the mirror line in units of the
283        objects being reflected. Defaults to 100.
284      add_title (bool): whether or not to add a title to the drawing.
285        Defaults to True.
286
287    Returns:
288      the Axes with the rendering of this transform added.
289
290    """
291    x, y = self.centre.x, self.centre.y
292    dx, dy = self.translation
293    r = np.sqrt(self.translation[0] ** 2 + self.translation[1] ** 2)
294    mirror = geom.LineString([
295      (x - mirror_length / 2 * np.cos(np.radians(self.angle)),
296       y - mirror_length / 2 * np.sin(np.radians(self.angle))),
297      (x + mirror_length / 2 * np.cos(np.radians(self.angle)),
298       y + mirror_length / 2 * np.sin(np.radians(self.angle)))])
299    gpd.GeoSeries([mirror]).plot(
300      ax = ax, color = "k", lw = 1, ls = "dashdot", zorder = 5)
301    no_slide = np.isclose(r, 0, rtol = 1e-6, atol = 1e-6)
302    if not no_slide:
303      ax.arrow(
304        x - dx / 2, y - dy / 2, dx, dy, length_includes_head = True,
305        width = w, fc = "k", ec = None, head_width = w * 6, zorder = 5)
306    if add_title:
307      w = ax.get_window_extent().width
308      ax.set_title(
309        f"{self.transform_type} {round(self.angle, 2)}", fontsize = w / 20)
310    return ax
311
312
313  def draw_translation(
314      self,
315      ax:plt.Axes,
316      c:geom.Point,
317      w:float = 5,
318      add_title:bool = True,
319    ) -> plt.Axes:
320    """Draw a translation transform on the supplied Axes.
321
322    Args:
323      ax (pyplot.Axes): axes on which to draw the representation.
324      c (geom.Point): an origin point in the coordinate space of the objects
325        from which to start the arrow representing the translation.
326      w (float): linewidth for the arrow. Defaults to 5.
327      add_title (bool): whether or not to add a title to the drawing.
328        Defaults to True.
329
330    Returns:
331      the Axes with the rendering of this transform added.
332
333    """
334    gpd.GeoSeries([c]).plot(ax = ax, color = "k")
335    ax.arrow(c.x, c.y, self.translation[0], self.translation[1],
336             length_includes_head = True, lw = 0.5, width = w,
337             fc = "k", ec = None, head_width = w * 6, zorder = 5)
338    if add_title:
339      w = ax.get_window_extent().width
340      ax.set_title(
341        (f"{self.transform_type} "
342         f"({self.translation[0]:.1f}, {self.translation[1]:.1f})"),
343        fontsize = w / 20)
344    return ax

Class to store details of a transform and draw it.

Transform( transform_type: str, angle: float, centre: shapely.geometry.point.Point, translation: tuple[float, ...], transform: tuple[float, ...])
transform_type: str

Type of transform 'rotation', 'reflection', 'translation' or 'identity'.

angle: float

Angle of rotation (degrees).

centre: shapely.geometry.point.Point

Centre of the transformation.

translation: tuple[float, ...]

X and Y coordinates shifts of a translation transform. A glide reflection may also include this.

transform: tuple[float, ...]

Six element tuple for the transform in shapely.transform format. See https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations and methods in weavingspace.tiling_utils.

def apply(self, geometry: shapely.lib.Geometry) -> shapely.lib.Geometry:
184  def apply(
185      self,
186      geometry:shapely.Geometry) -> shapely.Geometry:
187    """Apply this transform to supplied shapely geometry and return result.
188
189    Args:
190      geometry (shapely.Geometry): a shapely geometry to transform.
191
192    Returns:
193      shapely.Geometry: the resulting transformed shapely geometry.
194
195    """
196    return affine.affine_transform(geometry, self.transform)

Apply this transform to supplied shapely geometry and return result.

Args: geometry (shapely.Geometry): a shapely geometry to transform.

Returns: shapely.Geometry: the resulting transformed shapely geometry.

def draw( self, ax: matplotlib.axes._axes.Axes, **kwargs: str) -> matplotlib.axes._axes.Axes:
199  def draw(
200      self,
201      ax:plt.Axes,
202      **kwargs:str,
203    ) -> plt.Axes:
204    """Draw this transform on the supplied axes.
205
206    Arguments specific to each transform type are supplied as **kwargs and are
207    documented in `draw_rotation`, `draw_reflection`, and `draw_translation`.
208
209    Args:
210      ax (pyplot.axes): the axes on which to draw the transform.
211      kwargs: passed on to specific draw method.
212
213    Returns:
214      pyplot.Axes: the axes with the rendering of this transform added.
215
216    """
217    match self.transform_type:
218      case "rotation":
219        args = inspect.signature(self.draw_rotation).parameters
220        rotn_args = {k: kwargs.pop(k) for k in dict(kwargs) if k in args}
221        return self.draw_rotation(ax = ax, **rotn_args)
222      case "reflection":
223        args = inspect.signature(self.draw_reflection).parameters
224        refn_args = {k: kwargs.pop(k) for k in dict(kwargs) if k in args}
225        return self.draw_reflection(ax = ax, **refn_args)
226      case "translation":
227        args = inspect.signature(self.draw_translation).parameters
228        trans_args = {k: kwargs.pop(k) for k in dict(kwargs) if k in args}
229        return self.draw_translation(ax = ax, **trans_args)
230      case "identity":
231        w = ax.get_window_extent().width
232        return ax.set_title(f"{self.transform_type}", fontsize = w / 20)

Draw this transform on the supplied axes.

Arguments specific to each transform type are supplied as **kwargs and are documented in draw_rotation, draw_reflection, and draw_translation.

Args: ax (pyplot.axes): the axes on which to draw the transform. kwargs: passed on to specific draw method.

Returns: pyplot.Axes: the axes with the rendering of this transform added.

def draw_rotation( self, ax: matplotlib.axes._axes.Axes, radius: float = 200, add_title: bool = True) -> matplotlib.axes._axes.Axes:
235  def draw_rotation(
236      self,
237      ax:plt.Axes,
238      radius:float = 200,
239      add_title:bool = True,
240    ) -> plt.Axes:
241    """Draw a rotation transform on the supplied Axes.
242
243    Args:
244      ax (pyplot.Axes): axes on which to draw the representation.
245      radius (float): radius in object units to draw arcs representing the
246        angle. Defaults to 200.
247      add_title (bool): whether or not to add a title to the drawing.
248        Defaults to True.
249
250    Returns:
251      the Axes with the rendering of this transform added.
252
253    """
254    x, y = self.centre.x, self.centre.y
255    axis = geom.LineString([(x, y), (x + radius * 1.25, y)])
256    arc = geom.LineString([
257      geom.Point(x + radius * np.cos(np.radians(a)),
258                 y + radius * np.sin(np.radians(a)))
259      for a in np.linspace(0, self.angle, 50)])
260    gpd.GeoSeries(self.centre).plot(
261      ax = ax, color = "k", markersize = 4, zorder = 5)
262    gpd.GeoSeries([axis, arc]).plot(ax = ax, color = "k", lw = .5)
263    if add_title:
264      w = ax.get_window_extent().width
265      ax.set_title(
266        f"{self.transform_type} {round(self.angle, 2)}", fontsize = w / 20)
267    return ax

Draw a rotation transform on the supplied Axes.

Args: ax (pyplot.Axes): axes on which to draw the representation. radius (float): radius in object units to draw arcs representing the angle. Defaults to 200. add_title (bool): whether or not to add a title to the drawing. Defaults to True.

Returns: the Axes with the rendering of this transform added.

def draw_reflection( self, ax: matplotlib.axes._axes.Axes, w: float = 5, mirror_length: float = 100, add_title: bool = True) -> matplotlib.axes._axes.Axes:
270  def draw_reflection(
271      self,
272      ax:plt.Axes,
273      w:float = 5,
274      mirror_length:float = 100,
275      add_title:bool = True,
276    ) -> plt.Axes:
277    """Draw a reflection transform on the supplied Axes.
278
279    Args:
280      ax (pyplot.Axes): axes on which to draw the representation.
281      w (float): linewidth for the mirror line. Defaults to 5.
282      mirror_length (float): length of the mirror line in units of the
283        objects being reflected. Defaults to 100.
284      add_title (bool): whether or not to add a title to the drawing.
285        Defaults to True.
286
287    Returns:
288      the Axes with the rendering of this transform added.
289
290    """
291    x, y = self.centre.x, self.centre.y
292    dx, dy = self.translation
293    r = np.sqrt(self.translation[0] ** 2 + self.translation[1] ** 2)
294    mirror = geom.LineString([
295      (x - mirror_length / 2 * np.cos(np.radians(self.angle)),
296       y - mirror_length / 2 * np.sin(np.radians(self.angle))),
297      (x + mirror_length / 2 * np.cos(np.radians(self.angle)),
298       y + mirror_length / 2 * np.sin(np.radians(self.angle)))])
299    gpd.GeoSeries([mirror]).plot(
300      ax = ax, color = "k", lw = 1, ls = "dashdot", zorder = 5)
301    no_slide = np.isclose(r, 0, rtol = 1e-6, atol = 1e-6)
302    if not no_slide:
303      ax.arrow(
304        x - dx / 2, y - dy / 2, dx, dy, length_includes_head = True,
305        width = w, fc = "k", ec = None, head_width = w * 6, zorder = 5)
306    if add_title:
307      w = ax.get_window_extent().width
308      ax.set_title(
309        f"{self.transform_type} {round(self.angle, 2)}", fontsize = w / 20)
310    return ax

Draw a reflection transform on the supplied Axes.

Args: ax (pyplot.Axes): axes on which to draw the representation. w (float): linewidth for the mirror line. Defaults to 5. mirror_length (float): length of the mirror line in units of the objects being reflected. Defaults to 100. add_title (bool): whether or not to add a title to the drawing. Defaults to True.

Returns: the Axes with the rendering of this transform added.

def draw_translation( self, ax: matplotlib.axes._axes.Axes, c: shapely.geometry.point.Point, w: float = 5, add_title: bool = True) -> matplotlib.axes._axes.Axes:
313  def draw_translation(
314      self,
315      ax:plt.Axes,
316      c:geom.Point,
317      w:float = 5,
318      add_title:bool = True,
319    ) -> plt.Axes:
320    """Draw a translation transform on the supplied Axes.
321
322    Args:
323      ax (pyplot.Axes): axes on which to draw the representation.
324      c (geom.Point): an origin point in the coordinate space of the objects
325        from which to start the arrow representing the translation.
326      w (float): linewidth for the arrow. Defaults to 5.
327      add_title (bool): whether or not to add a title to the drawing.
328        Defaults to True.
329
330    Returns:
331      the Axes with the rendering of this transform added.
332
333    """
334    gpd.GeoSeries([c]).plot(ax = ax, color = "k")
335    ax.arrow(c.x, c.y, self.translation[0], self.translation[1],
336             length_includes_head = True, lw = 0.5, width = w,
337             fc = "k", ec = None, head_width = w * 6, zorder = 5)
338    if add_title:
339      w = ax.get_window_extent().width
340      ax.set_title(
341        (f"{self.transform_type} "
342         f"({self.translation[0]:.1f}, {self.translation[1]:.1f})"),
343        fontsize = w / 20)
344    return ax

Draw a translation transform on the supplied Axes.

Args: ax (pyplot.Axes): axes on which to draw the representation. c (geom.Point): an origin point in the coordinate space of the objects from which to start the arrow representing the translation. w (float): linewidth for the arrow. Defaults to 5. add_title (bool): whether or not to add a title to the drawing. Defaults to True.

Returns: the Axes with the rendering of this transform added.

@dataclass(slots=True, init=False)
class Symmetries:
347@dataclass(slots=True, init=False)
348class Symmetries:
349  """Class to identify and store symmetries of a shapely.Polygon."""
350
351  polygon:geom.Polygon
352  """Polygon from which these symmetries are derived."""
353  matcher:KMP_Matcher
354  """the subsequence matcher used in symmetry detection."""
355  n:int
356  """number of vertices of the polygon."""
357  poly_code:list[tuple[float,...]]
358  """the encoding of the polygon as sequence of length, angle pairs."""
359  poly_code_r:list[tuple[float,...]]
360  """the reversed encoding used to detect reflection symmetries."""
361  rotation_shifts:list[int]
362  """list of number of 2pi/n rotation symmetries."""
363  reflection_shifts:list[int]
364  """list of pi/n relection angle symmetries."""
365  symmetries:list[Transform]
366  """list of Transform objects with more complete information."""
367  symmetry_group:str
368  """the code denoting the symmetry group"""
369
370  def __init__(self, polygon:geom.Polygon) -> None:
371    """Construct a Symmetries instance."""
372    self.polygon = polygon
373    self.n = shapely.count_coordinates(self.polygon) - 1
374    self.poly_code = self._get_polygon_code(self.polygon)
375    self.poly_code_r = self._get_polygon_code(self.polygon, mirrored = True)
376    self.matcher = KMP_Matcher(self.poly_code + self.poly_code[:-1])
377    self.symmetries = self.get_symmetries()
378    self.symmetry_group = self.get_symmetry_group_code()
379
380
381  def _get_polygon_code(
382      self,
383      polygon:geom.Polygon,
384      mirrored:bool = False,
385    ) -> list[tuple[float,...]]:
386    r"""Return list of length-angle pairs to encode a polygon shape.
387
388    This encoding is unique up to scale. The alignment of lengths and angles is
389    important and symmetry detection is sensitively dependent on it...
390
391    The forward (i.e. unmirrored) polygon pairs are of edge lengths and the
392    angle between each edge and its successor going CW around the polygon.
393
394           A[i] ----L[i]---- A (i+1)
395          /                   \
396         /                     \
397
398    i.e. edge i is between angles i and i + 1, and angle i is between edge
399    i-1 and i. The mirrored encoding pairs length[i] with angle[i+1].
400
401    The mirrored encoding pairs matched indexes of lengths and angles in the
402    original polygon. This means the angle is still the one between and edge
403    and its successor but proceeding CCW around the polygon.
404
405    See in particular for clarification:
406
407      Eades P. 1988. Symmetry Finding Algorithms. In Machine Intelligence and
408      Pattern Recognition, ed. GT Toussaint, 6:41-51. Computational Morphology.
409      North-Holland. doi: 10.1016/B978-0-444-70467-2.50009-6.
410
411    Args:
412      polygon (geom.Polygon): the polygon to encode
413      mirrored (bool, optional): if true encoding will be in CCW order.
414        Defaults to False.
415
416    Returns:
417      list[tuple[float]]: a list of side-length angle pairs.
418
419    """
420    # get edge lengths and angles
421    lengths = tiling_utils.get_side_lengths(polygon)
422    raw_angles = tiling_utils.get_interior_angles(polygon)
423    if mirrored:
424      lengths_r = lengths[::-1]
425      angles_r = raw_angles[::-1]
426      return list(zip(lengths_r, angles_r, strict = True))
427    angles = raw_angles[1:] + raw_angles[:1]
428    return list(zip(lengths, angles, strict = True))
429
430
431  def get_symmetries(self) -> list[Transform]:
432    """Find rotation and reflection symmetries of a polygon.
433
434    The work is delegated to `get_rotations()` and `get_reflections()`.
435
436    Based on
437
438      Eades P. 1988. Symmetry Finding Algorithms. In Machine Intelligence and
439      Pattern Recognition, ed. GT Toussaint, 6:41-51. Computational Morphology.
440      North-Holland. doi: 10.1016/B978-0-444-70467-2.50009-6.
441
442    and also
443
444      Wolter JD, TC Woo, and RA Volz. 1985. Optimal algorithms for symmetry
445      detection in two and three dimensions. The Visual Computer 1(1): 37-48.
446      doi: 10.1007/BF01901268.
447
448    Details in these papers are not unambiguous. This implementation was
449    developed based on them, but with some trial and error to get the index
450    offsets and (especially) retrieval of the reflection axes angles to work.
451
452    Returns:
453      list[Transform]: a list of Transform objects representing the polygon
454        symmetries.
455
456    """
457    return self.get_rotations() + self.get_reflections()
458
459
460  def get_rotations(
461      self,
462      offsets:None|list[int] = None,
463    ) -> list[Transform]:
464    """Get the rotations associated with this collection of symmetries.
465
466    Returns:
467      list[Transform]: A list of the rotation symmetries associated with this
468        Symmetries instance's polygon.
469
470    """
471    if offsets is None:
472      self.rotation_shifts = self.matcher.find_matches(self.poly_code)
473      offsets = self.rotation_shifts
474    c = self.polygon.centroid
475    rot_angles = [np.round(i * 360 / self.n, 6) for i in offsets]
476    rotation_transforms = [tuple(
477      np.round(tiling_utils.get_rotation_transform(a, (c.x, c.y)), 6))
478      for a in rot_angles]
479    return [
480      Transform("rotation", angle, c, (0, 0), transform) for
481      transform, angle in zip(rotation_transforms, rot_angles, strict = True)]
482
483
484  def get_reflections(
485      self,
486      offsets:None|list[int] = None,
487    ) -> list[Transform]:
488    """Get the reflections associated with this collection of symmetries.
489
490    Args:
491      offsets (list[int]): list of integer offsets i where i / n * 360 where n
492        is the number of sides of this Symmetries instance's polygons is the
493        angle of the mirror in which a reflection symmetry has been identified.
494
495    Returns:
496      list[Transform]: A list of the reflection symmetries associated with this
497        Symmetries instance's polygon.
498
499    """
500    # collate all possible reflection axes - ordering of these is important
501    # for correct picking - so don't mess with this code!
502    bearings = tiling_utils.get_side_bearings(self.polygon)
503    reflection_axes = []
504    interior_angles = tiling_utils.get_interior_angles(self.polygon)
505    if offsets is None:
506      self.reflection_shifts = self.matcher.find_matches(self.poly_code_r)
507      offsets = self.reflection_shifts
508    for i in range(self.n):
509      # the angle bisectors
510      reflection_axes.append(bearings[i] - interior_angles[i] / 2)
511      # the edge_perpendicular bisectors
512      reflection_axes.append(bearings[i] - 90)  # normal to the edge
513    # pick out those where matches occurred using the offsets
514    ref_angles = [np.round(reflection_axes[i], 6) for i in offsets]
515    c = self.polygon.centroid
516    reflection_transforms = [tuple(
517      np.round(tiling_utils.get_reflection_transform(angle, (c.x, c.y)), 6))
518      for angle in ref_angles]
519    return [
520      Transform("reflection", angle, c, (0, 0), transform) for
521      transform, angle in zip(reflection_transforms, ref_angles, strict = True)]
522
523  def get_symmetry_group_code(self) -> str:
524    """Return symmetry group code for this instance's polygon.
525
526    See https://en.wikipedia.org/wiki/Symmetry_group#Two_dimensions.
527
528    Returns:
529        str: code such as C2 or D4 for cyclic or dihedral symmetry groups.
530
531    """
532    if len(self.reflection_shifts) == 0:
533      # no reflection symmetries, so it is a cyclic group Cn
534      return f"C{len(self.rotation_shifts)}"
535    # must be the dihedral group Dn
536    return f"D{len(self.rotation_shifts)}"
537
538
539  def get_corner_offset(
540      self,
541      poly2:geom.Polygon) -> int|None:
542    """Find offset between corners of this polygon and another one.
543
544    The offset is determined under whatever transformation will match one on
545    to the other. Used by the `Topology` class. The offset is expressed as the
546    number of corners clockwise by which the supplied polygon is offset from
547    this one.
548
549    Args:
550      poly2 (geom.Polygon): polygon for which the offset is desired.
551
552    Returns:
553      int|None: integer number of corners clockwise by which the polygon
554        is offset from this one, or None if they don't match.
555
556    """
557    s2 = Symmetries(poly2)
558    matches = self.matcher.find_matches(s2.poly_code)
559    if len(matches) > 0:
560      return matches[0]
561    matches = self.matcher.find_matches(s2.poly_code_r)
562    if len(matches) > 0:
563      return -matches[0]
564    return None

Class to identify and store symmetries of a shapely.Polygon.

Symmetries(polygon: shapely.geometry.polygon.Polygon)
370  def __init__(self, polygon:geom.Polygon) -> None:
371    """Construct a Symmetries instance."""
372    self.polygon = polygon
373    self.n = shapely.count_coordinates(self.polygon) - 1
374    self.poly_code = self._get_polygon_code(self.polygon)
375    self.poly_code_r = self._get_polygon_code(self.polygon, mirrored = True)
376    self.matcher = KMP_Matcher(self.poly_code + self.poly_code[:-1])
377    self.symmetries = self.get_symmetries()
378    self.symmetry_group = self.get_symmetry_group_code()

Construct a Symmetries instance.

polygon: shapely.geometry.polygon.Polygon

Polygon from which these symmetries are derived.

matcher: KMP_Matcher

the subsequence matcher used in symmetry detection.

n: int

number of vertices of the polygon.

poly_code: list[tuple[float, ...]]

the encoding of the polygon as sequence of length, angle pairs.

poly_code_r: list[tuple[float, ...]]

the reversed encoding used to detect reflection symmetries.

rotation_shifts: list[int]

list of number of 2pi/n rotation symmetries.

reflection_shifts: list[int]

list of pi/n relection angle symmetries.

symmetries: list[Transform]

list of Transform objects with more complete information.

symmetry_group: str

the code denoting the symmetry group

def get_symmetries(self) -> list[Transform]:
431  def get_symmetries(self) -> list[Transform]:
432    """Find rotation and reflection symmetries of a polygon.
433
434    The work is delegated to `get_rotations()` and `get_reflections()`.
435
436    Based on
437
438      Eades P. 1988. Symmetry Finding Algorithms. In Machine Intelligence and
439      Pattern Recognition, ed. GT Toussaint, 6:41-51. Computational Morphology.
440      North-Holland. doi: 10.1016/B978-0-444-70467-2.50009-6.
441
442    and also
443
444      Wolter JD, TC Woo, and RA Volz. 1985. Optimal algorithms for symmetry
445      detection in two and three dimensions. The Visual Computer 1(1): 37-48.
446      doi: 10.1007/BF01901268.
447
448    Details in these papers are not unambiguous. This implementation was
449    developed based on them, but with some trial and error to get the index
450    offsets and (especially) retrieval of the reflection axes angles to work.
451
452    Returns:
453      list[Transform]: a list of Transform objects representing the polygon
454        symmetries.
455
456    """
457    return self.get_rotations() + self.get_reflections()

Find rotation and reflection symmetries of a polygon.

The work is delegated to get_rotations() and get_reflections().

Based on

Eades P. 1988. Symmetry Finding Algorithms. In Machine Intelligence and Pattern Recognition, ed. GT Toussaint, 6:41-51. Computational Morphology. North-Holland. doi: 10.1016/B978-0-444-70467-2.50009-6.

and also

Wolter JD, TC Woo, and RA Volz. 1985. Optimal algorithms for symmetry detection in two and three dimensions. The Visual Computer 1(1): 37-48. doi: 10.1007/BF01901268.

Details in these papers are not unambiguous. This implementation was developed based on them, but with some trial and error to get the index offsets and (especially) retrieval of the reflection axes angles to work.

Returns: list[Transform]: a list of Transform objects representing the polygon symmetries.

def get_rotations( self, offsets: None | list[int] = None) -> list[Transform]:
460  def get_rotations(
461      self,
462      offsets:None|list[int] = None,
463    ) -> list[Transform]:
464    """Get the rotations associated with this collection of symmetries.
465
466    Returns:
467      list[Transform]: A list of the rotation symmetries associated with this
468        Symmetries instance's polygon.
469
470    """
471    if offsets is None:
472      self.rotation_shifts = self.matcher.find_matches(self.poly_code)
473      offsets = self.rotation_shifts
474    c = self.polygon.centroid
475    rot_angles = [np.round(i * 360 / self.n, 6) for i in offsets]
476    rotation_transforms = [tuple(
477      np.round(tiling_utils.get_rotation_transform(a, (c.x, c.y)), 6))
478      for a in rot_angles]
479    return [
480      Transform("rotation", angle, c, (0, 0), transform) for
481      transform, angle in zip(rotation_transforms, rot_angles, strict = True)]

Get the rotations associated with this collection of symmetries.

Returns: list[Transform]: A list of the rotation symmetries associated with this Symmetries instance's polygon.

def get_reflections( self, offsets: None | list[int] = None) -> list[Transform]:
484  def get_reflections(
485      self,
486      offsets:None|list[int] = None,
487    ) -> list[Transform]:
488    """Get the reflections associated with this collection of symmetries.
489
490    Args:
491      offsets (list[int]): list of integer offsets i where i / n * 360 where n
492        is the number of sides of this Symmetries instance's polygons is the
493        angle of the mirror in which a reflection symmetry has been identified.
494
495    Returns:
496      list[Transform]: A list of the reflection symmetries associated with this
497        Symmetries instance's polygon.
498
499    """
500    # collate all possible reflection axes - ordering of these is important
501    # for correct picking - so don't mess with this code!
502    bearings = tiling_utils.get_side_bearings(self.polygon)
503    reflection_axes = []
504    interior_angles = tiling_utils.get_interior_angles(self.polygon)
505    if offsets is None:
506      self.reflection_shifts = self.matcher.find_matches(self.poly_code_r)
507      offsets = self.reflection_shifts
508    for i in range(self.n):
509      # the angle bisectors
510      reflection_axes.append(bearings[i] - interior_angles[i] / 2)
511      # the edge_perpendicular bisectors
512      reflection_axes.append(bearings[i] - 90)  # normal to the edge
513    # pick out those where matches occurred using the offsets
514    ref_angles = [np.round(reflection_axes[i], 6) for i in offsets]
515    c = self.polygon.centroid
516    reflection_transforms = [tuple(
517      np.round(tiling_utils.get_reflection_transform(angle, (c.x, c.y)), 6))
518      for angle in ref_angles]
519    return [
520      Transform("reflection", angle, c, (0, 0), transform) for
521      transform, angle in zip(reflection_transforms, ref_angles, strict = True)]

Get the reflections associated with this collection of symmetries.

Args: offsets (list[int]): list of integer offsets i where i / n * 360 where n is the number of sides of this Symmetries instance's polygons is the angle of the mirror in which a reflection symmetry has been identified.

Returns: list[Transform]: A list of the reflection symmetries associated with this Symmetries instance's polygon.

def get_symmetry_group_code(self) -> str:
523  def get_symmetry_group_code(self) -> str:
524    """Return symmetry group code for this instance's polygon.
525
526    See https://en.wikipedia.org/wiki/Symmetry_group#Two_dimensions.
527
528    Returns:
529        str: code such as C2 or D4 for cyclic or dihedral symmetry groups.
530
531    """
532    if len(self.reflection_shifts) == 0:
533      # no reflection symmetries, so it is a cyclic group Cn
534      return f"C{len(self.rotation_shifts)}"
535    # must be the dihedral group Dn
536    return f"D{len(self.rotation_shifts)}"

Return symmetry group code for this instance's polygon.

See https://en.wikipedia.org/wiki/Symmetry_group#Two_dimensions.

Returns: str: code such as C2 or D4 for cyclic or dihedral symmetry groups.

def get_corner_offset(self, poly2: shapely.geometry.polygon.Polygon) -> int | None:
539  def get_corner_offset(
540      self,
541      poly2:geom.Polygon) -> int|None:
542    """Find offset between corners of this polygon and another one.
543
544    The offset is determined under whatever transformation will match one on
545    to the other. Used by the `Topology` class. The offset is expressed as the
546    number of corners clockwise by which the supplied polygon is offset from
547    this one.
548
549    Args:
550      poly2 (geom.Polygon): polygon for which the offset is desired.
551
552    Returns:
553      int|None: integer number of corners clockwise by which the polygon
554        is offset from this one, or None if they don't match.
555
556    """
557    s2 = Symmetries(poly2)
558    matches = self.matcher.find_matches(s2.poly_code)
559    if len(matches) > 0:
560      return matches[0]
561    matches = self.matcher.find_matches(s2.poly_code_r)
562    if len(matches) > 0:
563      return -matches[0]
564    return None

Find offset between corners of this polygon and another one.

The offset is determined under whatever transformation will match one on to the other. Used by the Topology class. The offset is expressed as the number of corners clockwise by which the supplied polygon is offset from this one.

Args: poly2 (geom.Polygon): polygon for which the offset is desired.

Returns: int|None: integer number of corners clockwise by which the polygon is offset from this one, or None if they don't match.

@dataclass(slots=True, init=False)
class ShapeMatcher:
567@dataclass(slots=True, init=False)
568class ShapeMatcher:
569  """Class to manage finding transforms that map one polygon on to another.
570
571  This was previously handled 'internally' by an earlier version of the
572  Symmetries code, but that quickly became ungainly and difficult to maintain.
573  This approach makes for cleaner code, and does the additional work of finding
574  e.g. centres of rotations (which may lie outside either or both polygons).
575  """
576
577  shape:geom.Polygon
578  """The target polygon on to which matching transforms are required."""
579  s1:Symmetries
580  """The Symmetries of the target polygon."""
581
582  def __init__(  # noqa: D107
583      self,
584      shape: geom.Polygon) -> None:
585    self.shape = shape
586    self.s1 = Symmetries(shape)
587
588
589  def get_polygon_matches(
590      self,
591      shape2:geom.Polygon) -> list[Transform]|None:
592    """Return all transforms that match shape2 onto this instance's polygon.
593
594    Args:
595      shape2 (geom.Polygon): polygon for which matching transforms are
596        requested.
597
598    Returns:
599      list[Transform]|None: a list of Transforms that will match shape2
600        onto this instance's shape. None is returned if no matching transforms
601        are found.
602
603    """
604    s2 = Symmetries(shape2)
605    if self.s1.symmetry_group != s2.symmetry_group:
606      return None
607    match_rot, rots = self._get_rotation_matches(s2)
608    match_ref, refs = self._get_reflection_matches(s2)
609    if match_rot and match_ref:
610      return rots + refs
611    if match_rot:
612      return rots
613    if match_ref:
614      return refs
615    return None
616
617
618  def _get_rotation_matches(
619      self,
620      s2:Symmetries) -> tuple[bool,list[Transform]]:
621    """Get rotations to match s2's polygon onto this instance's polygon.
622
623    Args:
624      s2 (Symmetries): Symmetries associated with the polygon of interest.
625
626    Returns:
627      tuple[bool,list[Transform]]: boolean is True if there are any matches
628        when matching transforms will be included in the associated list. If
629        there are no matches (False, []) will be returned.
630
631    """
632    # need this locally to check for equality of all members of a list
633    def all_equal(lst:list[float]) -> bool:
634      return all(np.isclose(lst[0], x, 1e-3, 1e-3) for x in lst)
635
636    matches = self.s1.matcher.find_matches(s2.poly_code)
637    if len(matches) == 0:
638      return False, None
639    transforms = []
640    # get lists of polygon corners aligned correctly to measure the angle
641    p1_corners = tiling_utils.get_corners(self.shape, repeat_first = False)
642    p2_corners = tiling_utils.get_corners(s2.polygon, repeat_first = False)
643    corner_distances = self._get_corner_distances_between_polys(
644      p1_corners, p2_corners, matches)
645    equalities = [all_equal(d) for d in corner_distances]
646    if all(equalities):
647      # the two shapes are identical and in the same spot
648      # so rotations symmetries are those of the target shape
649      transforms.extend(self.s1.get_rotations(matches))
650      return True, transforms
651    if any(equalities):
652      # the one where all are equal is a translation so we'll skip it below
653      # and add it here as a translation transform
654      m = equalities.index(True)
655      del matches[m]
656      dx, dy = (p1_corners[0].x - p2_corners[m].x,
657                p1_corners[0].y - p2_corners[m].y)
658      transforms.append(
659        Transform("translation", None, None, (dx, dy), (1, 0, 0, 1, dx, dy)))
660    for m in matches:
661      a, c = self._get_angle_between_polygons(p1_corners, p2_corners, m)
662      if a is None:
663        continue
664      transforms.append(Transform("rotation", a, c, (0, 0),
665        tiling_utils.get_rotation_transform(a, (c.x, c.y))))
666    return True, transforms
667
668
669  def _get_corner_distances_between_polys(
670      self,
671      corners1:list[geom.Point],
672      corners2:list[geom.Point],
673      offsets:list[int],
674    ) -> list[list[float]]:
675    return [
676      [p1.distance(p2) for p1, p2 in
677       zip(corners1, corners2[i:] + corners2[:i], strict = True)]
678      for i in offsets]
679
680
681  def _get_angle_between_polygons(
682      self,
683      corners1:list[geom.Point],
684      corners2:list[geom.Point],
685      offset:int,
686    ) -> tuple[None,...]|tuple[str,tuple[float,...]]|tuple[float,geom.Point]:
687    """Return angle by which lists of corners are rotationally offset.
688
689    If lists are at the same orientation then the translation required to match
690    them (if any) will be returned.
691
692    Args:
693      corners1 (list[geom.Point]): list of corners of the first polygon.
694      corners2 (list[geom.Point]): list of corners of the second polygon.
695      offset (int): number of clockwise steps through the corners by which
696        corners2 are offset from corners1 as previously determined.
697
698    Returns:
699      tuple[float,geom.Point]|tuple[str,tuple[float]]: one of
700      ('identity' (0, 0)), ('translation', (x, y)), or (angle, geom.Point),
701        where the last of these is an angle and a centre of rotation.
702
703    """
704    corners2 = corners2[offset:] + corners2[:offset]
705    dists = [
706      p1.distance(p2) for p1, p2 in zip(corners1, corners2, strict = True)]
707    if all(np.isclose(dists[0], d, atol = 1e-3, rtol = 1e-3) for d in dists):
708      # polygons already aligned similarly so we just need to find translation
709      if np.isclose(dists[0], 0, atol = 1e-3, rtol  = 1e-3):
710        return "identity", (0, 0)
711      if offset == 0:
712        return "translation", (corners1[0].x - corners2[0].x,
713                               corners1[0].y - corners2[0].y)
714    # Polygons not lined up. Order them by distances between them
715    ordered_dists = sorted([(i, d) for i, d in enumerate(dists)],
716                           key = lambda x: x[1], reverse = True)
717    # the two furthest will give the most reliable numbers
718    a, b = [i for i, d in ordered_dists[:2]]
719    p_1a, p_1b = corners1[a], corners1[b]
720    p_2a, p_2b = corners2[a], corners2[b]
721    # get the perpendiculars of the lines joining A and B in each polygon
722    perp_1a_2a = tiling_utils.get_straight_line(
723      p_1a, p_2a, perpendicular = True)
724    perp_1b_2b = tiling_utils.get_straight_line(
725      p_1b, p_2b, perpendicular = True)
726    # and intersect them
727    centre = tiling_utils.get_intersection(perp_1a_2a, perp_1b_2b)
728    if centre is None:
729      return None, None
730    angle = -tiling_utils.get_inner_angle(p_1a, centre, p_2a)
731    if angle < -179:
732      angle = angle + 360
733    elif angle > 180:
734      angle = angle - 360
735    return angle, centre
736
737
738  def _get_reflection_matches(
739      self,
740      s2:Symmetries,
741    ) -> tuple[bool,list[Transform]|None]:
742    """Get reflections that match s2's polygon to this instance's polygon.
743
744    Args:
745      s2 (Symmetries): Symmetries associated with the polygon of interest.
746
747    Returns:
748      tuple[bool,list[Transform]]: boolean is True if there are any matches
749        when matching transforms will be included in the associated list. If
750        there are no matches (False, []) will be returned.
751
752    """
753    matches = self.s1.matcher.find_matches(s2.poly_code_r)
754    if len(matches) == 0:
755      return False, None
756    # get mean centroid - all reflection matches will pass through this
757    c1 = self.s1.polygon.centroid
758    c2 = s2.polygon.centroid
759    c = ((c1.x + c2.x) / 2, (c1.y + c2.y) / 2)
760    # get each polygon's reflection symmetries
761    reflections1 = self.s1.get_reflections(matches)
762    reflections2 = s2.get_reflections(matches)
763    # their mean angle will be a line of reflection through c
764    angles = [(ref1.angle + ref2.angle) / 2
765              for ref1, ref2 in zip(reflections1, reflections2, strict = True)]
766    transforms = [tiling_utils.get_reflection_transform(angle, c)
767                  for angle in angles]
768    # there may also be a 'glide' component additional to the reflection
769    # to find it reflect the centre of polygon 2 by each transform and
770    # then find translation necessary to map it onto the centre of polygon 1
771    ctrs2r = [affine.affine_transform(c2, transform)
772              for transform in transforms]
773    dxdys = [(c1.x - ctr2r.x, c1.y - ctr2r.y) for ctr2r in ctrs2r]
774    return (True,
775      [Transform(
776        "reflection", angle, geom.Point(c), dxdy,
777        tiling_utils.combine_transforms([
778          tiling_utils.get_reflection_transform(angle, c),
779          tiling_utils.get_translation_transform(dxdy[0], dxdy[1])]))
780          for angle, dxdy in zip(angles, dxdys, strict = True)])

Class to manage finding transforms that map one polygon on to another.

This was previously handled 'internally' by an earlier version of the Symmetries code, but that quickly became ungainly and difficult to maintain. This approach makes for cleaner code, and does the additional work of finding e.g. centres of rotations (which may lie outside either or both polygons).

ShapeMatcher(shape: shapely.geometry.polygon.Polygon)
582  def __init__(  # noqa: D107
583      self,
584      shape: geom.Polygon) -> None:
585    self.shape = shape
586    self.s1 = Symmetries(shape)
shape: shapely.geometry.polygon.Polygon

The target polygon on to which matching transforms are required.

The Symmetries of the target polygon.

def get_polygon_matches( self, shape2: shapely.geometry.polygon.Polygon) -> list[Transform] | None:
589  def get_polygon_matches(
590      self,
591      shape2:geom.Polygon) -> list[Transform]|None:
592    """Return all transforms that match shape2 onto this instance's polygon.
593
594    Args:
595      shape2 (geom.Polygon): polygon for which matching transforms are
596        requested.
597
598    Returns:
599      list[Transform]|None: a list of Transforms that will match shape2
600        onto this instance's shape. None is returned if no matching transforms
601        are found.
602
603    """
604    s2 = Symmetries(shape2)
605    if self.s1.symmetry_group != s2.symmetry_group:
606      return None
607    match_rot, rots = self._get_rotation_matches(s2)
608    match_ref, refs = self._get_reflection_matches(s2)
609    if match_rot and match_ref:
610      return rots + refs
611    if match_rot:
612      return rots
613    if match_ref:
614      return refs
615    return None

Return all transforms that match shape2 onto this instance's polygon.

Args: shape2 (geom.Polygon): polygon for which matching transforms are requested.

Returns: list[Transform]|None: a list of Transforms that will match shape2 onto this instance's shape. None is returned if no matching transforms are found.