simetri.lace.lace

Simetri library's interlace objects.

   1"""Simetri library's interlace objects."""
   2
   3from itertools import combinations
   4from typing import Iterator, List, Any, Union
   5from collections import OrderedDict
   6
   7import networkx as nx
   8import numpy as np
   9
  10from numpy import isclose
  11
  12from ..graphics.shape import Shape, custom_attributes
  13from ..graphics.batch import Batch
  14
  15from ..colors import colors
  16from ..geometry.geometry import (
  17    connected_pairs,
  18    polygon_area,
  19    distance,
  20    double_offset_polygons,
  21    get_polygons,
  22    double_offset_polylines,
  23    intersection2,
  24    right_handed,
  25    polygon_cg,
  26    round_point,
  27    offset_polygon,
  28    offset_polygon_points,
  29    convex_hull,
  30    close_points2,
  31    polygon_center,
  32)
  33from ..helpers.graph import get_cycles
  34from ..graphics.common import get_defaults, common_properties
  35from ..graphics.all_enums import Types, Connection
  36from ..canvas.style_map import shape_style_map, ShapeStyle
  37from ..canvas.canvas import Canvas
  38from ..settings.settings import defaults
  39from ..helpers.utilities import flatten, group_into_bins
  40from ..helpers.validation import validate_args
  41
  42
  43def _set_style(obj: Any, attribs):
  44    for attr in attribs:
  45        setattr(obj, attr, getattr(defaults["style"], attr))
  46
  47
  48array = np.array
  49
  50
  51# Lace (Batch)
  52#     parallel_polyline_list (list)
  53#         parallel_polyline1 (ParallelPolyline-Batch-PARALLELPOLYLINE)
  54#             polyline_list (list)
  55#             |    polyline1 (Polyline-Shape)
  56#             |        divisions (list)
  57#             |            division1 (Division-Shape)
  58#             |                p1 (tuple)
  59#             |                p2 (tuple)
  60#             |                sections (list)
  61#             |                    section1(Section-Shape)
  62#             |                        p1 (tuple)
  63#             |                        p2
  64#             |                        is_overlap (bool)
  65#             |                        overlap (Overlap-Batch)
  66#             |overlaps (list)
  67#             |   overlap1(Overlap-Batch)
  68#             |       divisions (list)
  69#             |           division1(Division-Shape)
  70#             |               p1 (tuple)
  71#             |               p2 (tuple)
  72#             |               sections (list)
  73#             |                   section1(Section-Shape)
  74#             |                       start (Intersection-Shape)
  75#             |                       end (Intersection-Shape)
  76#             |                       overlap
  77#             |
  78#             |
  79#             |fragments
  80#                 fragment1(Shape)
  81#                     divisions
  82#                         division1(Shape)
  83#                             p1
  84#                             p2
  85#                             sections
  86#                                 section1
  87
  88#             plaits (list)
  89#                 plait1 (Plait-Shape)
  90
  91# All objects in this module is a subclass of the Shape or Batch class.
  92# They are used to compute the interlacing patterns.
  93
  94#  Example of a Lace object.
  95
  96#     /\
  97#    //\\ /\
  98#   //  \//\\
  99#  //   /\\ \\
 100# //   // \\ \\
 101# \\   \\ //  //
 102#  \\   \//  //
 103#   \\  //\\//
 104#    \\//  \/
 105#     \/
 106
 107# Example of a ParallelPolylines object. The lace object above has two
 108# ParallelPolylines objects. Main polylines are not shown, they are
 109# located in the middle of the offset polylines.
 110
 111#         /\
 112#        //\\
 113#       //  \\
 114#      //    \\
 115#      \\    //
 116#       \\  //
 117#        \\//
 118#         \/
 119
 120# Example of a Fragment object.
 121# They are polygons or polylines.
 122# The lace object above has three fragments.
 123# This is the middle fragment.
 124
 125#         /\
 126#        /  \
 127#        \  /
 128#         \/
 129
 130# Example of a plait object. They are polylines.
 131# Used for drawing under/over interlacing.
 132
 133#         /\
 134#         \ \
 135#          \ \
 136#          / /
 137#         / /
 138#         \/
 139
 140# Example of an Overlap object.
 141# The lace object above has two overlap regions.
 142
 143#         /\
 144#         \/
 145
 146# Example of a Polyline object.
 147
 148#          /\
 149#         /  \
 150#        /    \
 151#       /      \
 152#      /        \
 153#      \        /
 154#       \      /
 155#        \    /
 156#         \  /
 157#          \/
 158
 159# Example of an Division object.
 160# Each polyline is made up of one or more "Division" objects.
 161# Each division is divided into sections
 162# Maybe "Divisions" would be a better name instead of "Division"?
 163#            *
 164#             \
 165#              *
 166#               \
 167#                *
 168#                 \
 169#                  *
 170# Example of a Section object.
 171# Each division is made up of one or more sections.
 172# Sections have intersection points at their ends.
 173#                 *
 174#                  \
 175#                   *
 176# * intersections have a point, division1, division2 attributes.
 177# """
 178
 179class Intersection(Shape):
 180    """Intersection of two divisions. They are at the endpoints of Section
 181    objects. A division can have multiple sections and multiple
 182    intersections. They can be located at the end of a division.
 183
 184    Args:
 185        point (tuple): (x, y) coordinates of the intersection point.
 186        division1 (Division): First division.
 187        division2 (Division, optional): Second division. Defaults to None.
 188        endpoint (bool, optional): If the intersection is at the end of a division, then endpoint is True. Defaults to False.
 189        **kwargs: Additional attributes for cosmetic/drawing purposes.
 190    """
 191
 192    def __init__(self, point: tuple, division1: "Division", division2: "Division" = None, endpoint: bool = False, **kwargs) -> None:
 193        super().__init__([point], xform_matrix=None, subtype=Types.INTERSECTION, **kwargs)
 194        self._point = point
 195        self.division1 = division1
 196        self.division2 = division2
 197        self.overlap = None
 198        self.endpoint = endpoint
 199        self.division = None  # used for fragment divisions' DCEL structure
 200
 201        common_properties(self, id_only=True)
 202
 203    def _update(self, xform_matrix: array, reps=0):
 204        """Update the transformation matrix of the intersection.
 205
 206        Args:
 207            xform_matrix (array): Transformation matrix.
 208            reps (int, optional): Number of repetitions. Defaults to 0.
 209
 210        Returns:
 211            Any: Updated intersection or list of updated intersections.
 212        """
 213        if reps == 0:
 214            self.xform_matrix = self.xform_matrix @ xform_matrix
 215            res = self
 216        else:
 217            res = []
 218            for _ in range(reps):
 219                shape = self.copy()
 220                shape._update(xform_matrix)
 221                res.append(shape)
 222
 223        return res
 224
 225    def copy(self):
 226        """Create a copy of the intersection.
 227
 228        Returns:
 229            Intersection: A copy of the intersection.
 230        """
 231        intersection = Intersection(self.point, self.division1, self.division2)
 232        for attrib in shape_style_map:
 233            setattr(intersection, attrib, getattr(self, attrib))
 234        custom_attribs = custom_attributes(self)
 235        for attrib in custom_attribs:
 236            setattr(intersection, attrib, getattr(self, attrib))
 237        return intersection
 238
 239    @property
 240    def point(self):
 241        """Return the intersection point.
 242
 243        Returns:
 244            list: Intersection point coordinates.
 245        """
 246        return list(np.array([*self._point, 1.0]) @ self.xform_matrix)[:2]
 247
 248    def __str__(self):
 249        """String representation of the intersection.
 250
 251        Returns:
 252            str: String representation.
 253        """
 254        return (
 255            f"Intersection({[round(x, defaults['n_round']) for x in self.point]}, "
 256            f"{tuple(list([self.division1, self.division2]))}"
 257        )
 258
 259    def __repr__(self):
 260        """String representation of the intersection.
 261
 262        Returns:
 263            str: String representation.
 264        """
 265        return str(self)
 266
 267    def __eq__(self, other):
 268        """Check if two intersections are equal.
 269
 270        Args:
 271            other (Intersection): Another intersection.
 272
 273        Returns:
 274            bool: True if equal, False otherwise.
 275        """
 276        return close_points2(self.point, other.point, dist2=defaults["dist_tol"] ** 2)
 277
 278
 279class Partition(Shape):
 280    """These are the polygons of the non-interlaced geometry.
 281    Fragments and partitions are scaled versions of each other.
 282
 283    Args:
 284        points (list): List of points defining the partition.
 285        **kwargs: Additional attributes for cosmetic/drawing purposes.
 286    """
 287
 288    def __init__(self, points, **kwargs):
 289        super().__init__(points, **kwargs)
 290        self.subtype = Types.PART
 291        self.area = polygon_area(self.vertices)
 292        self.CG = polygon_cg(self.vertices)
 293        common_properties(self)
 294
 295    def __str__(self):
 296        """String representation of the partition.
 297
 298        Returns:
 299            str: String representation.
 300        """
 301        return f"Part({self.vertices})"
 302
 303    def __repr__(self):
 304        """String representation of the partition.
 305
 306        Returns:
 307            str: String representation.
 308        """
 309        return self.__str__()
 310
 311
 312class Fragment(Shape):
 313    """A Fragment is a collection of section objects that are connected
 314    to each other. These sections are already defined. They belong to
 315    the polyline objects in a lace. Fragments can be open or closed.
 316    They are created by the lace object.
 317
 318    Args:
 319        points (list): List of points defining the fragment.
 320        **kwargs: Additional attributes for cosmetic/drawing purposes.
 321    """
 322
 323    def __init__(self, points, **kwargs):
 324        super().__init__(points, **kwargs)
 325        self.subtype = Types.FRAGMENT
 326        self.area = polygon_area(self.vertices)
 327        self.sections = []
 328        self.intersections = []
 329        self.inner_lines = []
 330        self._divisions = []
 331        self.CG = polygon_cg(self.vertices)
 332        common_properties(self)
 333
 334    def __str__(self):
 335        """String representation of the fragment.
 336
 337        Returns:
 338            str: String representation.
 339        """
 340        return f"Fragment({self.vertices})"
 341
 342    def __repr__(self):
 343        """String representation of the fragment.
 344
 345        Returns:
 346            str: String representation.
 347        """
 348        return self.__str__()
 349
 350    @property
 351    def divisions(self):
 352        """Return the divisions of the fragment.
 353
 354        Returns:
 355            list: List of divisions.
 356        """
 357        return self._divisions
 358
 359    @property
 360    def center(self):
 361        """Return the center of the fragment.
 362
 363        Returns:
 364            list: Center coordinates.
 365        """
 366        return self.CG
 367
 368    def _set_divisions(self, dist_tol=None):
 369        if dist_tol is None:
 370            dist_tol = defaults["dist_tol"]
 371        dist_tol2 = dist_tol * dist_tol  # squared distance tolerance
 372        d_points__section = {}
 373        for section in self.sections:
 374            start = section.start.point
 375            end = section.end.point
 376            start = round(start[0], 2), round(start[1], 2)
 377            end = round(end[0], 2), round(end[1], 2)
 378            d_points__section[(start, end)] = section
 379            d_points__section[(end, start)] = section
 380
 381        coord_pairs = connected_pairs(self.vertices)
 382        self._divisions = []
 383        for pair in coord_pairs:
 384            x1, y1 = round_point(pair[0])
 385            x2, y2 = round_point(pair[1])
 386            division = Division((x1, y1), (x2, y2))
 387            division.section = d_points__section[((x1, y1), (x2, y2))]
 388            division.fragment = self
 389            start_point = round_point(division.section.start.point)
 390            end_point = round_point(division.section.end.point)
 391            if close_points2(start_point, (x1, y1), dist2=dist_tol2):
 392                division.intersections = [division.section.start, division.section.end]
 393                division.section.start.division = division
 394            elif close_points2(end_point, (x1, y1), dist2=dist_tol2):
 395                division.intersections = [division.section.end, division.section.start]
 396                division.section.end.division = division
 397            else:
 398                raise ValueError("Division does not match section")
 399            self._divisions.append(division)
 400        n = len(self._divisions)
 401        for i, division in enumerate(self._divisions):
 402            division.prev = self._divisions[i - 1]
 403            division.next = self._divisions[(i + 1) % n]
 404
 405    def _set_twin_divisions(self):
 406        for division in self.divisions:
 407            section = division.section
 408            if section.twin and section.twin.fragment:
 409                twin_fragment = section.twin.fragment
 410                distances = []
 411                for _, division2 in enumerate(twin_fragment.divisions):
 412                    dist = distance(
 413                        division.section.mid_point, division2.section.mid_point
 414                    )
 415                    distances.append((dist, division2))
 416                distances.sort(key=lambda x: x[0])
 417                division.twin = distances[0][1]
 418
 419
 420class Section(Shape):
 421    """A section is a line segment between two intersections.
 422    A division can have multiple sections. Sections are used to
 423    draw the over/under plaits.
 424
 425    Args:
 426        start (Intersection): Start intersection.
 427        end (Intersection): End intersection.
 428        is_overlap (bool, optional): If the section is an overlap. Defaults to False.
 429        overlap (Overlap, optional): Overlap object. Defaults to None.
 430        is_over (bool, optional): If the section is over. Defaults to False.
 431        twin (Section, optional): Twin section. Defaults to None.
 432        fragment (Fragment, optional): Fragment object. Defaults to None.
 433        **kwargs: Additional attributes for cosmetic/drawing purposes.
 434    """
 435
 436    def __init__(
 437        self,
 438        start: Intersection = None,
 439        end: Intersection = None,
 440        is_overlap: bool = False,
 441        overlap: "Overlap" = None,
 442        is_over: bool = False,
 443        twin: "Section" = None,
 444        fragment: "Fragment" = None,
 445        **kwargs,
 446    ):
 447        super().__init__([start.point, end.point], subtype=Types.SECTION, **kwargs)
 448        self.start = start
 449        self.end = end
 450        self.is_overlap = is_overlap
 451        self.overlap = overlap
 452        self.is_over = is_over
 453        self.twin = twin
 454        self.fragment = fragment
 455        super().__init__([start.point, end.point], subtype=Types.SECTION, **kwargs)
 456        self.length = distance(self.start.point, self.end.point)
 457        self.mid_point = [
 458            (self.start.point[0] + self.end.point[0]) / 2,
 459            (self.start.point[1] + self.end.point[1]) / 2,
 460        ]
 461        common_properties(self)
 462
 463    def copy(self):
 464        """Create a copy of the section.
 465
 466        Returns:
 467            Section: A copy of the section.
 468        """
 469        overlap = self.overlap.copy() if self.overlap else None
 470        start = self.start.copy()
 471        end = self.end.copy()
 472        section = Section(start, end, self.is_overlap, overlap, self.is_over)
 473
 474        return section
 475
 476    def end_point(self):
 477        """Return the end point of the section.
 478
 479        Returns:
 480            Intersection: End intersection.
 481        """
 482        if self.start.endpoint:
 483            res = self.start
 484        elif self.end.endpoint:
 485            res = self.end
 486        else:
 487            res = None
 488
 489        return res
 490
 491    def __str__(self):
 492        """String representation of the section.
 493
 494        Returns:
 495            str: String representation.
 496        """
 497        return f"Section({self.start}, {self.end})"
 498
 499    def __repr__(self):
 500        """String representation of the section.
 501
 502        Returns:
 503            str: String representation.
 504        """
 505        return self.__str__()
 506
 507    @property
 508    def is_endpoint(self):
 509        """Return True if the section is an endpoint.
 510
 511        Returns:
 512            bool: True if endpoint, False otherwise.
 513        """
 514        return self.start.endpoint or self.end.endpoint
 515
 516
 517class Overlap(Batch):
 518    """An overlap is a collection of four connected sections.
 519
 520    Args:
 521        intersections (list[Intersection], optional): List of intersections. Defaults to None.
 522        sections (list[Section], optional): List of sections. Defaults to None.
 523        visited (bool, optional): If the overlap is visited. Defaults to False.
 524        drawable (bool, optional): If the overlap is drawable. Defaults to True.
 525        **kwargs: Additional attributes for cosmetic/drawing purposes.
 526    """
 527
 528    def __init__(
 529        self,
 530        intersections: list[Intersection] = None,
 531        sections: list[Section] = None,
 532        visited=False,
 533        drawable=True,
 534        **kwargs,
 535    ):
 536        self.intersections = intersections
 537        self.sections = sections
 538        super().__init__(sections, **kwargs)
 539        self.subtype = Types.OVERLAP
 540        self.visited = visited
 541        self.drawable = drawable
 542        common_properties(self)
 543
 544    def __str__(self):
 545        """String representation of the overlap.
 546
 547        Returns:
 548            str: String representation.
 549        """
 550        return f"Overlap({self.id})"
 551
 552    def __repr__(self):
 553        """String representation of the overlap.
 554
 555        Returns:
 556            str: String representation.
 557        """
 558        return f"Overlap({self.id})"
 559
 560
 561class Division(Shape):
 562    """A division is a line segment between two intersections.
 563
 564    Args:
 565        p1 (tuple): Start point.
 566        p2 (tuple): End point.
 567        xform_matrix (array, optional): Transformation matrix. Defaults to None.
 568        **kwargs: Additional attributes for cosmetic/drawing purposes.
 569    """
 570
 571    def __init__(self, p1, p2, xform_matrix=None, **kwargs):
 572        super().__init__([p1, p2], subtype=Types.DIVISION, **kwargs)
 573        self.p1 = p1
 574        self.p2 = p2
 575        self.intersections = []
 576        self.twin = None  # used for fragment divisions only
 577        self.section = None  # used for fragment divisions only
 578        self.fragment = None  # used for fragment divisions only
 579        self.next = None  # used for fragment divisions only
 580        self.prev = None  # used for fragment divisions only
 581        self.sections = []
 582        super().__init__(
 583            [p1, p2], subtype=Types.DIVISION, xform_matrix=xform_matrix, **kwargs
 584        )
 585        common_properties(self)
 586
 587    def _update(self, xform_matrix, reps=0):
 588        """Update the transformation matrix of the division.
 589
 590        Args:
 591            xform_matrix (array): Transformation matrix.
 592            reps (int, optional): Number of repetitions. Defaults to 0.
 593
 594        Returns:
 595            Any: Updated division or list of updated divisions.
 596        """
 597        if reps == 0:
 598            self.xform_matrix = self.xform_matrix @ xform_matrix
 599            res = self
 600        else:
 601            res = []
 602            for _ in range(reps):
 603                shape = self.copy()
 604                shape._update(xform_matrix)
 605                res.append(shape)
 606
 607        return res
 608
 609    def __str__(self):
 610        """String representation of the division.
 611
 612        Returns:
 613            str: String representation.
 614        """
 615        return (
 616            f"Division(({self.p1[0]:.2f}, {self.p1[1]:.2f}), "
 617            f"({self.p2[0]:.2f}, {self.p2[1]:.2f}))"
 618        )
 619
 620    def __repr__(self):
 621        """String representation of the division.
 622
 623        Returns:
 624            str: String representation.
 625        """
 626        return (
 627            f"Division(({self.p1[0]:.2f}, {self.p1[1]:.2f}), "
 628            f"({self.p2[0]:.2f}, {self.p2[1]:.2f}))"
 629        )
 630
 631    def copy(
 632        self,
 633        section: Section = None,
 634        twin: Section = None,
 635    ):
 636        """Create a copy of the division.
 637
 638        Args:
 639            section (Section, optional): Section object. Defaults to None.
 640            twin (Section, optional): Twin section. Defaults to None.
 641
 642        Returns:
 643            Division: A copy of the division.
 644        """
 645        division = Division(self.p1[:], self.p2[:], np.copy(self.xform_matrix))
 646        for attrib in shape_style_map:
 647            setattr(division, attrib, getattr(self, attrib))
 648        division.twin = twin
 649        division.section = section
 650        division.fragment = self.fragment
 651        division.next = self.next
 652        division.prev = self.prev
 653        division.sections = [x.copy() for x in self.sections]
 654        custom_attributes_ = custom_attributes(self)
 655        for attrib in custom_attributes_:
 656            setattr(division, attrib, getattr(self, attrib))
 657        return division
 658
 659    def _merged_sections(self):
 660        """Merge sections of the division.
 661
 662        Returns:
 663            list: List of merged sections.
 664        """
 665        chains = []
 666        chain = [self.intersections[0]]
 667        sections = self.sections[:]
 668        for section in sections:
 669            if not section.is_over:
 670                if section.start.id == chain[-1].id:
 671                    chain.append(section.end)
 672                else:
 673                    chains.append(chain)
 674                    chain = [section.start, section.end]
 675        if chain not in chains:
 676            chains.append(chain)
 677        return chains
 678
 679    def _sort_intersections(self) -> None:
 680        """Sort intersections of the division."""
 681        self.intersections.sort(key=lambda x: distance(self.p1, x.point))
 682
 683    def is_connected(self, other: "Division") -> bool:
 684        """Return True if the division is connected to another division.
 685
 686        Args:
 687            other (Division): Another division.
 688
 689        Returns:
 690            bool: True if connected, False otherwise.
 691        """
 692        return self.p1 in other.end_points or self.p2 in other.end_points
 693
 694    @property
 695    def end_points(self):
 696        """Return the end points of the division.
 697
 698        Returns:
 699            list: List of end points.
 700        """
 701        return [self.p1, self.p2]
 702
 703    @property
 704    def start(self) -> Intersection:
 705        """Return the start intersection of the division.
 706
 707        Returns:
 708            Intersection: Start intersection.
 709        """
 710        return self.intersections[0]
 711
 712    @property
 713    def end(self) -> Intersection:
 714        """Return the end intersection of the division.
 715
 716        Returns:
 717            Intersection: End intersection.
 718        """
 719        return self.intersections[-1]
 720
 721
 722class Polyline(Shape):
 723    """
 724    Connected points, similar to Shape objects.
 725    They can be closed or open.
 726    They are defined by a sequence of points.
 727    They have divisions, sections, and intersections.
 728
 729    Args:
 730        points (list): List of points defining the polyline.
 731        closed (bool, optional): If the polyline is closed. Defaults to True.
 732        xform_matrix (array, optional): Transformation matrix. Defaults to None.
 733        **kwargs: Additional attributes for cosmetic/drawing purposes.
 734    """
 735
 736    def __init__(self, points, closed=True, xform_matrix=None, **kwargs):
 737        self.__dict__["style"] = ShapeStyle()
 738        self.__dict__["_style_map"] = shape_style_map
 739        self._set_aliases()
 740        self.closed = closed
 741        kwargs["subtype"] = Types.POLYLINE
 742        super().__init__(points, closed=closed, xform_matrix=xform_matrix, **kwargs)
 743        self._set_divisions()
 744        if not self.closed:
 745            self._set_intersections()
 746        common_properties(self)
 747
 748    def _update(self, xform_matrix, reps=0):
 749        """Update the transformation matrix of the polyline.
 750
 751        Args:
 752            xform_matrix (array): Transformation matrix.
 753            reps (int, optional): Number of repetitions. Defaults to 0.
 754
 755        Returns:
 756            Any: Updated polyline or list of updated polylines.
 757        """
 758        if reps == 0:
 759            self.xform_matrix = self.xform_matrix @ xform_matrix
 760            for division in self.divisions:
 761                division._update(xform_matrix, reps=reps)
 762            res = self
 763        else:
 764            res = []
 765            for _ in range(reps):
 766                shape = self.copy()
 767                shape._update(xform_matrix)
 768                res.append(shape)
 769
 770        return res
 771
 772    def __str__(self):
 773        """String representation of the polyline.
 774
 775        Returns:
 776            str: String representation.
 777        """
 778        return f"Polyline({self.final_coords[:, :2]})"
 779
 780    def __repr__(self):
 781        """String representation of the polyline.
 782
 783        Returns:
 784            str: String representation.
 785        """
 786        return self.__str__()
 787
 788    def iter_sections(self) -> Iterator:
 789        """Iterate over the sections of the polyline.
 790
 791        Yields:
 792            Section: Section object.
 793        """
 794        for division in self.divisions:
 795            yield from division.sections
 796
 797    def iter_intersections(self):
 798        """Iterate over the intersections of the polyline.
 799
 800        Yields:
 801            Intersection: Intersection object.
 802        """
 803        for division in self.divisions:
 804            yield from division.intersections
 805
 806    @property
 807    def intersections(self):
 808        """Return the intersections of the polyline.
 809
 810        Returns:
 811            list: List of intersections.
 812        """
 813        res = []
 814        for division in self.divisions:
 815            res.extend(division.intersections)
 816        return res
 817
 818    @property
 819    def area(self):
 820        """Return the area of the polygon.
 821
 822        Returns:
 823            float: Area of the polygon.
 824        """
 825        return polygon_area(self.vertices)
 826
 827    @property
 828    def sections(self):
 829        """Return the sections of the polyline.
 830
 831        Returns:
 832            list: List of sections.
 833        """
 834        sections = []
 835        for division in self.divisions:
 836            sections.extend(division.sections)
 837        return sections
 838
 839    @property
 840    def divisions(self):
 841        """Return the divisions of the polyline.
 842
 843        Returns:
 844            list: List of divisions.
 845        """
 846        return self.__dict__["divisions"]
 847
 848    def _set_divisions(self):
 849        vertices = self.vertices
 850        if self.closed:
 851            vertices = list(vertices) + [vertices[0]]
 852        pairs = connected_pairs(vertices)
 853        divisions = [Division(p1, p2) for p1, p2 in pairs]
 854        self.__dict__["divisions"] = divisions
 855
 856    def _set_intersections(self):
 857        """Fake intersections for open lines."""
 858        division1 = self.divisions[0]
 859        division2 = self.divisions[-1]
 860        x1 = Intersection(division1.p1, division1, None, True)
 861        division1.intersections = [x1]
 862        x2 = Intersection(division2.p2, division2, None, True)
 863        if division1.id == division2.id:
 864            division1.intersections.append(x2)
 865        else:
 866            division2.intersections = [x2]
 867
 868
 869class ParallelPolyline(Batch):
 870    """A ParallelPolylines is a collection of parallel Polylines.
 871    They are defined by a main polyline and a list of offset
 872    values (that can be negative or positive).
 873
 874    Args:
 875        polyline (Polyline): Main polyline.
 876        offset (float): Offset value.
 877        lace (Lace): Lace object.
 878        under (bool, optional): If the polyline is under. Defaults to False.
 879        closed (bool, optional): If the polyline is closed. Defaults to True.
 880        dist_tol (float, optional): Distance tolerance. Defaults to None.
 881        **kwargs: Additional attributes for cosmetic/drawing purposes.
 882    """
 883
 884    def __init__(
 885        self,
 886        polyline,
 887        offset,
 888        lace,
 889        under=False,
 890        closed=True,
 891        dist_tol=None,
 892        **kwargs,
 893    ):
 894        if dist_tol is None:
 895            dist_tol = defaults["dist_tol"]
 896        dist_tol2 = dist_tol * dist_tol
 897        self.polyline = polyline
 898        self.offset = offset
 899        self.dist_tol = dist_tol
 900        self.dist_tol2 = dist_tol2
 901        self.closed = closed
 902        self._set_offset_polylines()
 903        self.polyline_list = [self.polyline] + self.offset_poly_list
 904        super().__init__(self.polyline_list, **kwargs)
 905        self.subtype = Types.PARALLEL_POLYLINE
 906        self.overlaps = None
 907        self.under = under
 908        common_properties(self)
 909
 910    @property
 911    def sections(self) -> List[Section]:
 912        """Return the sections of the parallel polyline.
 913
 914        Returns:
 915            list: List of sections.
 916        """
 917        sects = []
 918        for polyline in self.polyline_list:
 919            sects.extend(polyline.sections)
 920        return sects
 921
 922    def _set_offset_polylines(self):
 923        polyline = self.polyline
 924        if self.closed:
 925            vertices = list(polyline.vertices)
 926            vertices = vertices + [vertices[0]]
 927            offset_polygons = double_offset_polygons(
 928                vertices, self.offset, dist_tol=self.dist_tol)
 929        else:
 930            offset_polylines = double_offset_polylines(polyline.vertices, self.offset)
 931        polylines = []
 932        if self.closed:
 933            for polygon in offset_polygons:
 934                polylines.append(Polyline(polygon, closed=self.closed))
 935        else:
 936            for polyline in offset_polylines:
 937                polylines.append(Polyline(polyline, closed=self.closed))
 938
 939        self.offset_poly_list = polylines
 940
 941
 942class Lace(Batch):
 943    """
 944    A Lace is a collection of ParallelPolylines objects.
 945    They are used to create interlace patterns.
 946
 947    Args:
 948        polygon_shapes (Union[Batch, list[Shape]], optional): List of polygon shapes. Defaults to None.
 949        polyline_shapes (Union[Batch, list[Shape]], optional): List of polyline shapes. Defaults to None.
 950        offset (float, optional): Offset value. Defaults to 2.
 951        rtol (float, optional): Relative tolerance. Defaults to None.
 952        swatch (list, optional): Swatch list. Defaults to None.
 953        breakpoints (list, optional): Breakpoints list. Defaults to None.
 954        plait_color (colors.Color, optional): Plait color. Defaults to None.
 955        draw_fragments (bool, optional): If fragments should be drawn. Defaults to True.
 956        palette (list, optional): Palette list. Defaults to None.
 957        color_step (int, optional): Color step. Defaults to 1.
 958        with_plaits (bool, optional): If plaits should be included. Defaults to True.
 959        area_threshold (float, optional): Area threshold. Defaults to None.
 960        radius_threshold (float, optional): Radius threshold. Defaults to None.
 961        **kwargs: Additional attributes for cosmetic/drawing purposes.
 962    """
 963
 964    # @timing
 965    def __init__(
 966        self,
 967        polygon_shapes: Union[Batch, list[Shape]] = None,
 968        polyline_shapes: Union[Batch, list[Shape]] = None,
 969        offset: float = 2,
 970        rtol: float = None,
 971        swatch: list = None,
 972        breakpoints: list = None,
 973        plait_color: colors.Color = None,
 974        draw_fragments: bool = True,
 975        palette: list = None,
 976        color_step: int = 1,
 977        with_plaits: bool = True,
 978        area_threshold: float = None,
 979        radius_threshold: float = None,
 980        **kwargs,
 981    ) -> None:
 982        validate_args(kwargs, shape_style_map)
 983        (
 984            rtol,
 985            swatch,
 986            plait_color,
 987            draw_fragments,
 988            area_threshold,
 989            radius_threshold,
 990        ) = get_defaults(
 991            [
 992                "rtol",
 993                "swatch",
 994                "plait_color",
 995                "draw_fragments",
 996                "area_threshold",
 997                "radius_threshold",
 998            ],
 999            [
1000                rtol,
1001                swatch,
1002                plait_color,
1003                draw_fragments,
1004                area_threshold,
1005                radius_threshold,
1006            ],
1007        )
1008        if polygon_shapes:
1009            polygon_shapes = polygon_shapes.merge_shapes()
1010            self.polygon_shapes = self._check_polygons(polygon_shapes)
1011        else:
1012            self.polygon_shapes = []
1013        if polyline_shapes:
1014            polyline_shapes = polyline_shapes.merge_shapes()
1015            self.polyline_shapes = self._check_polylines(polyline_shapes)
1016        else:
1017            self.polyline_shapes = []
1018        if not self.polygon_shapes and not self.polyline_shapes:
1019            msg = "Lace.__init__ : No polygons or polylines found."
1020            raise ValueError(msg)
1021        self.polyline_shapes = polyline_shapes
1022        self.offset = offset
1023        self.main_intersections = None
1024        self.offset_intersections = None
1025        self.xform_matrix = np.eye(3)
1026        self.rtol = rtol
1027        self.swatch = swatch
1028        self.breakpoints = breakpoints
1029        self.plait_color = plait_color
1030        self.draw_fragments = draw_fragments
1031        self.palette = palette
1032        self.color_step = color_step
1033        self.with_plaits = with_plaits
1034        self.d_intersections = {}  # key, value:intersection.id, intersection
1035        self.d_connections = {}
1036        self.plaits = []
1037        self.overlaps = []
1038        self._groups = None
1039        self.area_threshold = area_threshold
1040        self.radius_threshold = radius_threshold
1041        if kwargs and "_copy" in kwargs:
1042            # pass the pre-computed values
1043            for k, v in kwargs:
1044                if k == "_copy":
1045                    continue
1046                else:
1047                    setattr(self, k, v)
1048        else:
1049            self._set_polyline_list()  # main divisions are set here along with polylines
1050            # polyline.divisions is the list of Division objects
1051            self._set_parallel_poly_list()
1052            # start_time2 = time.perf_counter()
1053            self._set_intersections()
1054            # end_time2 = time.perf_counter()
1055            # print(
1056            #     f"Lace.__init__ intersections computed in {end_time2 - start_time2:0.4f} seconds"
1057            # )
1058
1059            self._set_overlaps()
1060            self._set_twin_sections()
1061            self._set_fragments()
1062            if not self.polyline_shapes:
1063                self._set_outline()
1064            # self._set_partitions()
1065            self._set_over_under()
1066            if self.with_plaits:
1067                self.set_plaits()
1068            # self._set_convex_hull()
1069            # self._set_concave_hull()
1070            # self._set_fragment_groups()
1071            # self._set_partition_groups()
1072            self._b_box = None
1073        elements = [polyline for polyline in self.parallel_poly_list] + self.fragments
1074
1075        if "debug" in kwargs:
1076            kwargs.pop("debug")
1077        super().__init__(elements, **kwargs)
1078        if kwargs and "_copy" not in kwargs:
1079            for k, v in kwargs.items():
1080                if k in shape_style_map:
1081                    setattr(self, k, v)  # todo: we should check for valid values here
1082                else:
1083                    raise AttributeError(f"{k}. Invalid attribute!")
1084        self.subtype = Types.LACE
1085        common_properties(self)
1086
1087    @property
1088    def center(self):
1089        """Return the center of the lace.
1090
1091        Returns:
1092            list: Center coordinates.
1093        """
1094        return self.outline.CG
1095
1096    @property
1097    def fragment_groups(self):
1098        """Return the fragment groups of the lace.
1099
1100        Returns:
1101            dict: Dictionary of fragment groups.
1102        """
1103        center = self.center
1104        radius_frag = []
1105        for fragment in self.fragments:
1106            radius = int(distance(center, fragment.CG))
1107            for rad, frag in radius_frag:
1108                if abs(radius - rad) <= 2:
1109                    radius = rad
1110                    break
1111            radius_frag.append((radius, fragment))
1112
1113        radius_frag.sort(key=lambda x: x[0])
1114
1115        d_groups = {}
1116        for i, (radius, fragment) in enumerate(radius_frag):
1117            if i == 0:
1118                d_groups[radius] = [fragment]
1119            else:
1120                if radius in d_groups:
1121                    d_groups[radius].append(fragment)
1122                else:
1123                    d_groups[radius] = [fragment]
1124
1125        return d_groups
1126
1127    def _check_polygons(self, polygon_shapes):
1128        if isinstance(polygon_shapes, Batch):
1129            polygon_shapes = polygon_shapes.all_shapes
1130        for polygon in polygon_shapes:
1131            if len(polygon.primary_points) < 3:
1132                msg = "Lace.__init__ found polygon with less than 3 points."
1133                raise ValueError(msg)
1134            if not polygon.closed:
1135                msg = "Lace.__init__ : Invalid polygons"
1136                raise ValueError(msg)
1137            if polygon.primary_points[0] != polygon.primary_points[-1]:
1138                polygon.primary_points.append(polygon.primary_points[0])
1139        # check if the polygons are clockwise
1140        for polygon in polygon_shapes:
1141            if not right_handed(polygon.vertices):
1142                polygon.primary_points.reverse()
1143
1144        return polygon_shapes
1145
1146    def _check_polylines(self, polyline_shapes):
1147        if isinstance(polyline_shapes, Batch):
1148            polyline_shapes = polyline_shapes.all_shapes
1149        for polyline in polyline_shapes:
1150            if len(polyline.primary_points) < 2:
1151                msg = "Lace.__init__ found polyline with less than 2 points."
1152                raise ValueError(msg)
1153
1154        return polyline_shapes
1155
1156    def _update(self, xform_matrix, reps=0):
1157        """Update the transformation matrix of the lace.
1158
1159        Args:
1160            xform_matrix (array): Transformation matrix.
1161            reps (int, optional): Number of repetitions. Defaults to 0.
1162
1163        Returns:
1164            Any: Updated lace or list of updated laces.
1165        """
1166        if reps == 0:
1167            self.xform_matrix = self.xform_matrix @ xform_matrix
1168            for polygon in self.polygon_shapes:
1169                polygon._update(xform_matrix)
1170
1171            if self.polyline_shapes:
1172                for polyline in self.polyline_shapes:
1173                    polyline._update(xform_matrix)
1174
1175            for polyline in self.parallel_poly_list:
1176                polyline._update(xform_matrix)
1177
1178            for fragment in self.fragments:
1179                fragment._update(xform_matrix)
1180
1181            for intersection in self.main_intersections:
1182                intersection._update(xform_matrix)
1183
1184            for intersection in self.offset_intersections:
1185                intersection._update(xform_matrix)
1186
1187            for overlap in self.overlaps:
1188                overlap._update(xform_matrix)
1189
1190            for plait in self.plaits:
1191                plait._update(xform_matrix)
1192
1193            return self
1194        else:
1195            res = []
1196            for _ in range(reps):
1197                shape = self.copy()
1198                shape._update(xform_matrix)
1199                res.append(shape)
1200            return res
1201
1202    # @timing
1203    def _set_twin_sections(self):
1204        for par_poly in self.parallel_poly_list:
1205            poly1, poly2 = par_poly.offset_poly_list
1206            for i, sec in enumerate(poly1.iter_sections()):
1207                sec1 = sec
1208                sec2 = poly2.sections[i]
1209                sec1.twin = sec2
1210                sec2.twin = sec1
1211
1212    # @timing
1213    def _set_partitions(self):
1214        for fragment in self.fragments:
1215            self.partitions = []
1216            for fragment in self.fragments:
1217                partition = Shape(offset_polygon(fragment.vertices, self.offset))
1218                self.partitions.append(partition)
1219
1220    # To do: This doesn't work if we have polyline shapes!
1221    def _set_outline(self):
1222        # outline is a special fragment that covers the whole lace
1223        areas = []
1224        for fragment in self.fragments:
1225            areas.append((fragment.area, fragment))
1226        areas.sort(reverse=True, key=lambda x: x[0])
1227        self.outline = areas[0][1]
1228        self.fragments.remove(self.outline)
1229        # perimenter is the outline of the partitions
1230        self.perimeter = Shape(offset_polygon(self.outline.vertices, -self.offset))
1231        # skeleton is the input polylines that the lace is based on
1232        self.skeleton = Batch(self.polyline_list)
1233
1234    def set_fragment_groups(self):
1235        # to do : handle repeated code. same in _set_partition_groups
1236        areas = []
1237        for i, fragment in enumerate(self.fragments):
1238            areas.append((fragment.area, i))
1239        areas.sort()
1240        bins = group_into_bins(areas, self.area_threshold)
1241        self.fragments_by_area = OrderedDict()
1242        for i, bin in enumerate(bins):
1243            area_values = [x[0] for x in bin]
1244            key = sum([x[0] for x in bin]) / len(bin)
1245            fragments = []
1246            for area, ind in areas:
1247                if area in area_values:
1248                    fragments.append(self.fragments[ind])
1249            self.fragments_by_area[key] = fragments
1250
1251        radii = []
1252        for i, fragment in enumerate(self.fragments):
1253            radii.append((distance(self.center, fragment.CG), i))
1254        radii.sort()
1255        bins = group_into_bins(radii, self.radius_threshold)
1256        self.fragments_by_radius = OrderedDict()
1257        for i, bin in enumerate(bins):
1258            radius_values = [x[0] for x in bin]
1259            key = sum([x[0] for x in bin]) / len(bin)
1260            fragments = []
1261            for radius, ind in radii:
1262                if radius in radius_values:
1263                    fragments.append(self.fragments[ind])
1264            self.fragments_by_radius[key] = fragments
1265
1266    # @timing
1267    def _set_partition_groups(self):
1268        # to do : handle repeated code. same in set_fragment_groups
1269        areas = []
1270        for i, partition in enumerate(self.partitions):
1271            areas.append((partition.area, i))
1272        areas.sort()
1273        bins = group_into_bins(areas, self.area_threshold)
1274        self.partitions_by_area = OrderedDict()
1275        for i, bin_ in enumerate(bins):
1276            area_values = [x[0] for x in bin_]
1277            key = sum([x[0] for x in bin_]) / len(bin_)
1278            partitions = []
1279            for area, ind in areas:
1280                if area in area_values:
1281                    partitions.append(self.partitions[ind])
1282            self.partitions_by_area[key] = partitions
1283
1284        radii = []
1285        for i, partition in enumerate(self.partitions):
1286            CG = polygon_center(partition.vertices)
1287            radii.append((distance(self.center, CG), i))
1288        radii.sort()
1289        bins = group_into_bins(radii, self.radius_threshold)
1290        self.partitions_by_radius = OrderedDict()
1291        for i, bin_ in enumerate(bins):
1292            radius_values = [x[0] for x in bin_]
1293            key = sum([x[0] for x in bin_]) / len(bin_)
1294            partitions = []
1295            for radius, ind in radii:
1296                if radius in radius_values:
1297                    partitions.append(self.partitions[ind])
1298            self.partitions_by_radius[key] = partitions
1299
1300    # @timing
1301    def _set_fragments(self):
1302        G = nx.Graph()
1303        for section in self.iter_offset_sections():
1304            if section.is_overlap:
1305                continue
1306            G.add_edge(section.start.id, section.end.id, section=section)
1307
1308        cycles = nx.cycle_basis(G)
1309        fragments = []
1310        d_x = self.d_intersections
1311        for cycle in cycles:
1312            cycle.append(cycle[0])
1313            nodes = cycle
1314            edges = connected_pairs(cycle)
1315            sections = [G.edges[edge]["section"] for edge in edges]
1316            s_intersections = set()
1317            for section in sections:
1318                s_intersections.add(section.start.id)
1319                s_intersections.add(section.end.id)
1320            intersections = [self.d_intersections[i] for i in s_intersections]
1321            points = [d_x[x_id].point for x_id in nodes]
1322            if not right_handed(points):
1323                points.reverse()
1324            fragment = Fragment(points)
1325            fragment.sections = sections
1326            fragment.intersections = intersections
1327            fragments.append(fragment)
1328
1329        for fragment in fragments:
1330            for section in fragment.sections:
1331                section.fragment = fragment
1332
1333        for fragment in fragments:
1334            fragment._set_divisions()
1335        for fragment in fragments:
1336            fragment._set_twin_divisions()
1337
1338        self.fragments = fragments
1339
1340    def _set_concave_hull(self):
1341        self.concave_hull = self.outline.vertices
1342
1343    def _set_convex_hull(self):
1344        self.convex_hull = convex_hull(self.outline.vertices)
1345
1346    def copy(self):
1347        class Dummy(Lace):
1348            pass
1349
1350        # we need to copy the polyline_list and parallel_poly_list
1351        for polyline in self.polyline_list:
1352            polyline.copy()
1353
1354    def get_sketch(self):
1355        """
1356        Create and return a Sketch object. Sketch is a Batch object
1357        with Shape elements corresponding to the vertices of the plaits
1358        and fragments of the Lace instance. They have 'plaits' and
1359        'fragments' attributes to hold lists of Shape objects populated
1360        with plait and fragment vertices of the Lace instance
1361        respectively. They are used for drawing multiple copies of the
1362        original lace pattern. They are light-weight compared to the
1363        Lace objects since they only contain sufficient data to draw the
1364        lace objects. Hundreds of these objects can be used to create
1365        wallpaper patterns or other patterns without having to contain
1366        unnecessary data. They do not share points with the original
1367        Lace object.
1368
1369        Arguments:
1370        ----------
1371            None
1372
1373        Prerequisites:
1374        --------------
1375            * A lace object to be copied.
1376
1377        Side effects:
1378        -------------
1379            None
1380
1381        Return:
1382        --------
1383            A Sketch object.
1384        """
1385        fragments = []
1386        for fragment in self.fragments:
1387            polygon = Shape(fragment.vertices)
1388            polygon.fill = True
1389            polygon.subtype = Types.FRAGMENT
1390            fragments.append(polygon)
1391
1392        plaits = []
1393        for plait in self.plaits:
1394            polygon = Shape(plait.vertices)
1395            polygon.fill = True
1396            polygon.subtype = Types.PLAIT
1397            plaits.append(polygon)
1398
1399        sketch = Batch((fragments + plaits))
1400        sketch.fragments = fragments
1401        sketch.plaits = plaits
1402        sketch.outline = self.outline
1403        sketch.subtype = Types.SKETCH
1404
1405        sketch.draw_plaits = True
1406        sketch.draw_fragments = True
1407        return sketch
1408
1409    def group_fragments(self, tol=None):
1410        """Group the fragments by the number of vertices and the area.
1411
1412        Args:
1413            tol (float, optional): Tolerance value. Defaults to None.
1414
1415        Returns:
1416            list: List of grouped fragments.
1417        """
1418        if tol is None:
1419            tol = defaults["tol"]
1420        frags = self.fragments
1421        vert_groups = [
1422            [frag for frag in frags if len(frag.vertices) == n]
1423            for n in set([len(f.vertices) for f in frags])
1424        ]
1425        groups = []
1426        for group in vert_groups:
1427            areas = [
1428                [f for f in group if isclose(f.area, area, rtol=tol)]
1429                for area in set([frag.area for frag in group])
1430            ]
1431            areas.sort(key=lambda x: x[0].area, reverse=True)
1432            groups.append(areas)
1433        groups.sort(key=lambda x: x[0][0].area, reverse=True)
1434
1435        return groups
1436
1437    def get_fragment_cycles(self):
1438        """
1439        Iterate over the offset sections and create a graph of the
1440        intersections (start and end of the sections). Then find the
1441        cycles in the graph. self.d_intersections is used to map
1442        the graph nodes to the actual intersection points.
1443
1444        Returns:
1445            list: List of fragment cycles.
1446        """
1447        graph_edges = []
1448        for section in self.iter_offset_sections():
1449            if section.is_overlap:
1450                continue
1451            graph_edges.append((section.start.id, section.end.id))
1452
1453        return get_cycles(graph_edges)
1454
1455    def _set_inner_lines(self, item, n, offset, line_color=colors.blue, line_width=1):
1456        for i in range(n):
1457            vertices = item.vertices
1458            dist_tol = defaults["dist_tol"]
1459            offset_poly = offset_polygon_points(
1460                vertices, offset * (i + 1), dist_tol=dist_tol
1461            )
1462            shape = Shape(offset_poly)
1463            shape.fill = False
1464            shape.line_width = line_width
1465            shape.line_color = line_color
1466            item.inner_lines.append(shape)
1467
1468    def set_plait_lines(self, n, offset, line_color=colors.blue, line_width=1):
1469        """Create offset lines inside the plaits of the lace.
1470
1471        Args:
1472            n (int): Number of lines.
1473            offset (float): Offset value.
1474            line_color (colors.Color, optional): Line color. Defaults to colors.blue.
1475            line_width (int, optional): Line width. Defaults to 1.
1476        """
1477        for plait in self.plaits:
1478            plait.inner_lines = []
1479            self._set_inner_lines(plait, n, offset, line_color, line_width)
1480
1481    def set_fragment_lines(
1482        self,
1483        n: int,
1484        offset: float,
1485        line_color: colors.Color = colors.blue,
1486        line_width=1,
1487    ) -> None:
1488        """
1489        Create offset lines inside the fragments of the lace.
1490
1491        Args:
1492            n (int): Number of lines.
1493            offset (float): Offset value.
1494            line_color (colors.Color, optional): Line color. Defaults to colors.blue.
1495            line_width (int, optional): Line width. Defaults to 1.
1496        """
1497        for fragment in self.fragments:
1498            fragment.inner_lines = []
1499            self._set_inner_lines(fragment, n, offset, line_color, line_width)
1500
1501    @property
1502    def all_divisions(self) -> List:
1503        """
1504        Return a list of all the divisions (both main and offset) in the lace.
1505
1506        Returns:
1507            list: List of all divisions.
1508        """
1509        res = []
1510        for parallel_polyline in self.parallel_poly_list:
1511            for polyline in parallel_polyline.polyline_list:
1512                res.extend(polyline.divisions)
1513        return res
1514
1515    def iter_main_intersections(self) -> Iterator:
1516        """Iterate over the main intersections.
1517
1518        Yields:
1519            Intersection: Intersection object.
1520        """
1521        for ppoly in self.parallel_poly_list:
1522            for division in ppoly.polyline.divisions:
1523                yield from division.intersections
1524
1525    def iter_offset_intersections(self) -> Iterator:
1526        """
1527        Iterate over the offset intersections.
1528
1529        Yields:
1530            Intersection: Intersection object.
1531        """
1532        for ppoly in self.parallel_poly_list:
1533            for poly in ppoly.offset_poly_list:
1534                for division in poly.divisions:
1535                    yield from division.intersections
1536
1537    def iter_offset_sections(self) -> Iterator:
1538        """
1539        Iterate over the offset sections.
1540
1541        Yields:
1542            Section: Section object.
1543        """
1544        for ppoly in self.parallel_poly_list:
1545            for poly in ppoly.offset_poly_list:
1546                for division in poly.divisions:
1547                    yield from division.sections
1548
1549    def iter_main_sections(self) -> Iterator:
1550        """Iterate over the main sections.
1551
1552        Yields:
1553            Section: Section object.
1554        """
1555        for ppoly in self.parallel_poly_list:
1556            for division in ppoly.polyline.divisions:
1557                yield from division.sections
1558
1559    def iter_offset_divisions(self) -> Iterator:
1560        """
1561        Iterate over the offset divisions.
1562
1563        Yields:
1564            Division: Division object.
1565        """
1566        for ppoly in self.parallel_poly_list:
1567            for poly in ppoly.offset_poly_list:
1568                yield from poly.divisions
1569
1570    def iter_main_divisions(self) -> Iterator:
1571        """
1572        Iterate over the main divisions.
1573
1574        Yields:
1575            Division: Division object.
1576        """
1577        for ppoly in self.parallel_poly_list:
1578            yield from ppoly.polyline.divisions
1579
1580    @property
1581    def main_divisions(self) -> List[Division]:
1582        """Main divisions are the divisions of the main polyline.
1583
1584        Returns:
1585            list: List of main divisions.
1586        """
1587        res = []
1588        for parallel_polyline in self.parallel_poly_list:
1589            res.extend(parallel_polyline.polyline.divisions)
1590        return res
1591
1592    @property
1593    def offset_divisions(self) -> List[Division]:
1594        """Offset divisions are the divisions of the offset polylines.
1595
1596        Returns:
1597            list: List of offset divisions.
1598        """
1599        res = []
1600        for parallel_polyline in self.parallel_poly_list:
1601            for polyline in parallel_polyline.offset_poly_list:
1602                res.extend(polyline.divisions)
1603        return res
1604
1605    @property
1606    def intersections(self) -> List[Intersection]:
1607        """Return all the intersections in the parallel_poly_list.
1608
1609        Returns:
1610            list: List of intersections.
1611        """
1612        res = []
1613        for parallel_polyline in self.parallel_poly_list:
1614            for polyline in parallel_polyline.polyline_list:
1615                res.extend(polyline.intersections)
1616        return res
1617
1618    # @timing
1619    def _set_polyline_list(self):
1620        """
1621        Populate the self.polyline_list list with Polyline objects.
1622
1623        * Internal use only.
1624
1625        Arguments:
1626        ----------
1627            None
1628
1629        Return:
1630        --------
1631            None
1632
1633        Prerequisites:
1634        --------------
1635
1636            * self.polygon_shapes and/or self.polyline_shapes must be
1637              established.
1638        """
1639        self.polyline_list = []
1640        if self.polygon_shapes:
1641            for polygon in self.polygon_shapes:
1642                self.polyline_list.append(Polyline(polygon.vertices, closed=True))
1643        if self.polyline_shapes:
1644            for polyline in self.polyline_shapes:
1645                self.polyline_list.append(Polyline(polyline.vertices, closed=False))
1646
1647    # @timing
1648    def _set_parallel_poly_list(self):
1649        """
1650        Populate the self.parallel_poly_list list with ParallelPolyline
1651        objects.
1652
1653        Arguments:
1654        ----------
1655            None
1656
1657        Return:
1658        --------
1659            None
1660
1661        Prerequisites:
1662        --------------
1663            * self.polygon_shapes and/or self.polyline_shapes must be
1664              established prior to this.
1665            * Parallel polylines are created by offsetting the original
1666              polygon and polyline shapes in two directions using the
1667              self.offset value.
1668
1669        Notes:
1670        ------
1671            This method is called by the Lace constructor.  It is not
1672            for users to call directly. Without this method, the Lace
1673            object cannot be created.
1674        """
1675        self.parallel_poly_list = []
1676        if self.polyline_list:
1677            for _, polyline in enumerate(self.polyline_list):
1678                self.parallel_poly_list.append(
1679                    ParallelPolyline(
1680                        polyline,
1681                        self.offset,
1682                        lace=self,
1683                        closed=polyline.closed,
1684                        dist_tol=defaults["dist_tol"]
1685                    )
1686                )
1687
1688    # @timing
1689    def _set_overlaps(self):
1690        """
1691        Populate the self.overlaps list with Overlap objects. Side
1692        effects listed below.
1693
1694        Arguments:
1695        ----------
1696            None
1697
1698        Return:
1699        --------
1700            None
1701
1702        Side Effects:
1703        -------------
1704            * self.overlaps is populated with Overlap objects.
1705            * Section objects' overlap attribute is populated with the
1706              corresponding Overlap object that they are a part of. Not
1707              all sections will have an overlap.
1708
1709        Prerequisites:
1710        --------------
1711            self.polyline and self.parallel_poly_list must be populated.
1712            self.main_intersections, self.offset_sections and
1713            self.d_intersections must be populated prior to creating
1714            the overlaps.
1715
1716        Notes:
1717        ------
1718            This method is called by the Lace constructor.  It is not
1719            for users to call directly.
1720            Without this method, the Lace object cannot be created.
1721        """
1722        G = nx.Graph()
1723        for section in self.iter_offset_sections():
1724            if section.is_overlap:
1725                G.add_edge(section.start.id, section.end.id, section=section)
1726        cycles = nx.cycle_basis(G)
1727        for cycle in cycles:
1728            cycle.append(cycle[0])
1729            edges = connected_pairs(cycle)
1730            sections = [G.edges[edge]["section"] for edge in edges]
1731            s_intersections = set()
1732            for section in sections:
1733                s_intersections.add(section.start.id)
1734                s_intersections.add(section.end.id)
1735            intersections = [self.d_intersections[i] for i in s_intersections]
1736            overlap = Overlap(intersections=intersections, sections=sections)
1737            for section in sections:
1738                section.overlap = overlap
1739            for edge in edges:
1740                section = G.edges[edge]["section"]
1741                section.start.overlap = overlap
1742                section.end.overlap = overlap
1743            self.overlaps.append(overlap)
1744        for overlap in self.overlaps:
1745            for section in overlap.sections:
1746                if section.is_over:
1747                    line_width = 3
1748                else:
1749                    line_width = 1
1750                line = Shape(
1751                    [section.start.point, section.end.point], line_width=line_width
1752                )
1753        for division in self.offset_divisions:
1754            p1 = division.start.point
1755            p2 = division.end.point
1756
1757    def set_plaits(self):
1758        """
1759        Populate the self.plaits list with Plait objects. Plaits are
1760        optional for drawing. They form the under/over interlacing. They
1761        are created if the "with_plaits" argument is set to be True in
1762        the constructor. with_plaits is True by default but this can be
1763        changed by setting the auto_plaits value to False in the
1764        settings.py This method can be called by the user to create the
1765        plaits after the creation of the Lace object if they were not
1766        created initally.
1767
1768        * Can be called by users.
1769
1770        Arguments:
1771        ----------
1772            None
1773
1774        Return:
1775        --------
1776            None
1777
1778        Side Effects:
1779        -------------
1780            * self.plaits is populated with Plait objects.
1781
1782        Prerequisites:
1783        --------------
1784            self.polyline and self.parallel_poly_list must be populated.
1785            self.divisions and self.intersections must be populated.
1786            self.overlaps must be populated.
1787
1788        Where used:
1789        -----------
1790            Lace.__init__
1791
1792        Notes:
1793        ------
1794            This method is called by the Lace constructor.  It is not
1795            for users to call directly. Without this method, the Lace
1796            object cannot be created.
1797        """
1798        if self.plaits:
1799            return
1800        plait_sections = []
1801        for division in self.iter_offset_divisions():
1802            merged_sections = division._merged_sections()
1803            for merged in merged_sections:
1804                plait_sections.append((merged[0], merged[-1]))
1805
1806        # connect the open ends of the polyline_shapes
1807        for ppoly in self.parallel_poly_list:
1808            if not ppoly.closed:
1809                polyline1 = ppoly.offset_poly_list[0]
1810                polyline2 = ppoly.offset_poly_list[1]
1811                p1_start_x = polyline1.intersections[0]
1812                p1_end_x = polyline1.intersections[-1]
1813                p2_start_x = polyline2.intersections[0]
1814                p2_end_x = polyline2.intersections[-1]
1815
1816                plait_sections.append((p1_start_x, p2_start_x))
1817                plait_sections.append((p1_end_x, p2_end_x))
1818        for sec in self.iter_offset_sections():
1819            if not sec.is_over and sec.is_overlap:
1820                plait_sections.append((sec.start, sec.end))
1821
1822        graph_edges = [(r[0].id, r[1].id) for r in plait_sections]
1823        cycles = get_cycles(graph_edges)
1824        plaits = []
1825        count = 0
1826        for cycle in cycles:
1827            cycle = connected_pairs(cycle)
1828            dup = cycle[1:]
1829            plait = [cycle[0][0], cycle[0][1]]
1830            for _ in range(len(cycle) - 1):
1831                last = plait[-1]
1832                for edge in dup:
1833                    if edge[0] == last:
1834                        plait.append(edge[1])
1835                        dup.remove(edge)
1836                        break
1837                    if edge[1] == last:
1838                        plait.append(edge[0])
1839                        dup.remove(edge)
1840                        break
1841            plaits.append(plait)
1842            count += 1
1843        d_x = self.d_intersections
1844        for plait in plaits:
1845            intersections = [d_x[x] for x in plait]
1846            vertices = [x.point for x in intersections]
1847            if not right_handed(vertices):
1848                vertices.reverse()
1849                plait.reverse()
1850            shape = Shape(vertices)
1851            shape.intersections = intersections
1852            shape.fill_color = colors.gold
1853            shape.inner_lines = None
1854            shape.subtype = Types.PLAIT
1855            self.plaits.append(shape)
1856
1857    # @timing
1858    def _set_intersections(self):
1859        """
1860        Compute all intersection points (by calling all_intersections)
1861        among the divisions of the polylines (both main and offset).
1862        Populate the self.main_intersections and
1863        self.offset_intersections lists with Intersection objects. This
1864        method is called by the Lace constructor and customized to be
1865        used with Lace objects only. Without this method, the Lace
1866        object cannot be created.
1867
1868        * Internal use only!
1869
1870        Arguments:
1871        ----------
1872            None
1873
1874        Return:
1875        --------
1876            None
1877
1878        Side Effects:
1879        -------------
1880            * self.main_intersections are populated.
1881            * self.offset_intersections are populated.
1882            * "sections" attribute of the divisions are populated.
1883            * "is_overlap" attribute of the sections are populated.
1884            * "intersections" attribute of the divisions are populated.
1885            * "endpoint" attribute of the intersections are populated.
1886
1887        Where used:
1888        -----------
1889            Lace.__init__
1890
1891        Prerequisites:
1892        --------------
1893            * self.main_divisions must be populated.
1894            * self.offset_divisions must be populated.
1895            * Two endpoint intersections of the divisions must be set.
1896
1897        Notes:
1898        ------
1899            This method is called by the Lace constructor.  It is not
1900            for users to call directly. Without this method, the Lace
1901            object cannot be created.
1902            Works only for regular under/over interlacing.
1903        """
1904        # set intersections for the main polylines
1905        main_divisions = self.main_divisions
1906        offset_divisions = self.offset_divisions
1907        self.main_intersections = all_intersections(
1908            main_divisions, self.d_intersections, self.d_connections
1909        )
1910
1911        # set sections for the main divisions
1912        self.main_sections = []
1913        for division in main_divisions:
1914            division.intersections[0].endpoint = True
1915            division.intersections[-1].endpoint = True
1916            segments = connected_pairs(division.intersections)
1917            division.sections = []
1918            for i, segment in enumerate(segments):
1919                section = Section(*segment)
1920                if i % 2 == 1:
1921                    section.is_overlap = True
1922                division.sections.append(section)
1923                self.main_sections.append(section)
1924        # set intersections for the offset polylines
1925        self.offset_intersections = all_intersections(
1926            offset_divisions, self.d_intersections, self.d_connections
1927        )
1928        # set sections for the offset divisions
1929        self.offset_sections = []
1930        for i, division in enumerate(offset_divisions):
1931            division.intersections[0].endpoint = True
1932            division.intersections[-1].endpoint = True
1933            lines = connected_pairs(division.intersections)
1934            division.sections = []
1935            for j, line in enumerate(lines):
1936                section = Section(*line)
1937                if j % 2 == 1:
1938                    section.is_overlap = True
1939                division.sections.append(section)
1940                self.offset_sections.append(section)
1941
1942    def _all_polygons(self, polylines, rtol=None):
1943        """Return a list of polygons from a list of lists of points.
1944        polylines: [[(x1, y1), (x2, y2)], [(x3, y3), (x4, y4)], ...]
1945        return [[(x1, y1), (x2, y2), (x3, y3), ...], ...]
1946
1947        Args:
1948            polylines (list): List of lists of points.
1949            rtol (float, optional): Relative tolerance. Defaults to None.
1950
1951        Returns:
1952            list: List of polygons.
1953        """
1954        if rtol is None:
1955            rtol = self.rtol
1956        return get_polygons(polylines, rtol)
1957
1958    # @timing
1959    def _set_over_under(self):
1960        def next_poly(exclude):
1961            for ppoly in self.parallel_poly_list:
1962                poly1, poly2 = ppoly.offset_poly_list
1963                if poly1 in exclude:
1964                    continue
1965                ind = 0
1966                for sec in poly1.iter_sections():
1967                    if sec.is_overlap:
1968                        if sec.overlap.drawable and sec.overlap.visited:
1969                            even_odd = ind % 2 == 1
1970                            return (poly1, poly2, even_odd)
1971                        ind += 1
1972            return (None, None, None)
1973
1974        for ppoly in self.parallel_poly_list:
1975            poly1, poly2 = ppoly.offset_poly_list
1976            exclude = []
1977            even_odd = 0
1978            while poly1:
1979                ind = 0
1980                for i, division in enumerate(poly1.divisions):
1981                    for j, section in enumerate(division.sections):
1982                        if section.is_overlap:
1983                            if section.overlap is None:
1984                                msg = (
1985                                    "Overlap section in the lace has no "
1986                                    "overlap object.\n"
1987                                    "Try different offset value and/or "
1988                                    "tolerance."
1989                                )
1990                                raise RuntimeError(msg)
1991                            section.overlap.visited = True
1992                            if ind % 2 == even_odd:
1993                                if section.overlap.drawable:
1994                                    section.overlap.drawable = False
1995                                    section.is_over = True
1996                                    section2 = poly2.divisions[i].sections[j]
1997                                    section2.is_over = True
1998                            ind += 1
1999                exclude.extend([poly1, poly2])
2000                poly1, poly2, even_odd = next_poly(exclude)
2001
2002    def fragment_edge_graph(self) -> nx.Graph:
2003        """
2004        Return a networkx graph of the connected fragments.
2005        If two fragments have a "common" division then they are connected.
2006
2007        Returns:
2008            nx.Graph: Graph of connected fragments.
2009        """
2010        G = nx.Graph()
2011        fragments = [(f.area, f) for f in self.fragments]
2012        fragments.sort(key=lambda x: x[0], reverse=True)
2013        for fragment in [f[1] for f in fragments[1:]]:
2014            for division in fragment.divisions:
2015                if division.twin and division.twin.fragment:
2016                    fragment2 = division.twin.fragment
2017                    G.add_node(fragment.id, fragment=fragment)
2018                    G.add_node(fragment2.id, fragment=fragment2)
2019                    G.add_edge(fragment.id, fragment2.id, edge=division)
2020        return G
2021
2022    def fragment_vertex_graph(self) -> nx.Graph:
2023        """
2024        Return a networkx graph of the connected fragments.
2025        If two fragments have a "common" vertex then they are connected.
2026
2027        Returns:
2028            nx.Graph: Graph of connected fragments.
2029        """
2030        def get_neighbours(intersection):
2031            division = intersection.division
2032            if not division.next.twin:
2033                return None
2034            fragments = [division.fragment]
2035            start_division_id = division.id
2036            if division.next.twin:
2037                twin_id = division.next.twin.id
2038            else:
2039                return None
2040            while twin_id != start_division_id:
2041                if division.next.twin:
2042                    if division.fragment.id not in [x.id for x in fragments]:
2043                        fragments.append(division.fragment)
2044                    twin_id = division.next.twin.id
2045                    division = division.next.twin
2046                else:
2047                    if division.next.fragment.id not in [x.id for x in fragments]:
2048                        fragments.append(division.next.fragment)
2049                    break
2050
2051            return fragments
2052
2053        neighbours = []
2054        for fragment in self.fragments:
2055            for intersection in fragment.intersections:
2056                neighbours.append(get_neighbours(intersection))
2057        G = self.fragment_edge_graph()
2058        G2 = nx.Graph()
2059        for n in neighbours:
2060            if n:
2061                if len(n) > 2:
2062                    for pair in combinations(n, 2):
2063                        ids = tuple([x.id for x in pair])
2064                        if ids not in G.edges:
2065                            G2.add_node(ids[0], fragment=pair[0])
2066                            G2.add_node(ids[1], fragment=pair[1])
2067                            G2.add_edge(*ids)
2068        return G2
2069
2070
2071def all_intersections(
2072    division_list: list[Division],
2073    d_intersections: dict[int, Intersection],
2074    d_connections: dict[frozenset, Intersection],
2075    loom=False,
2076) -> list[Intersection]:
2077    """
2078    Find all intersections of the given divisions. Sweep-line algorithm
2079    without a self-balancing tree. Instead of a self-balancing tree,
2080    it uses a numpy array to sort and filter the divisions. For the
2081    number of divisions that are commonly needed in a lace, this is
2082    sufficiently fast. It is also more robust and much easier to
2083    understand and debug. Tested with tens of thousands of divisions but
2084    not millions. The book has a section on this algorithm.
2085    simetri.geometry.py has another version (called
2086    all_intersections) for finding intersections among a given
2087    list of divisions.
2088
2089    Arguments:
2090    ----------
2091        division_list: list of Division objects.
2092
2093    Side Effects:
2094    -------------
2095        * Modifies the given division objects (in the division_list) in place
2096            by adding the intersections to the divisions' "intersections"
2097            attribute.
2098        * Updates the d_intersections
2099        * Updates the d_connections
2100
2101    Return:
2102    --------
2103        A list of all intersection objects among the given division
2104        list.
2105    """
2106    # register fake intersections at the endpoints of the open lines
2107    for division in division_list:
2108        if division.intersections:
2109            for x in division.intersections:
2110                d_intersections[x.id] = x
2111
2112    # All objects are assigned an integer id attribute when they are created
2113    division_array = array(
2114        [flatten(division.vertices) + [division.id] for division in division_list]
2115    )
2116    n_divisions = division_array.shape[0]  # number of divisions
2117    # precompute the min and max x and y values for each division
2118    # these will be used with the sweep line algorithm
2119    xmin = np.minimum(division_array[:, 0], division_array[:, 2]).reshape(
2120        n_divisions, 1
2121    )
2122    xmax = np.maximum(division_array[:, 0], division_array[:, 2]).reshape(
2123        n_divisions, 1
2124    )
2125    ymin = np.minimum(division_array[:, 1], division_array[:, 3]).reshape(
2126        n_divisions, 1
2127    )
2128    ymax = np.maximum(division_array[:, 1], division_array[:, 3]).reshape(
2129        n_divisions, 1
2130    )
2131    division_array = np.concatenate((division_array, xmin, ymin, xmax, ymax), 1)
2132    d_divisions = {}
2133    for division in division_list:
2134        d_divisions[division.id] = division
2135    i_id, i_xmin, i_ymin, i_xmax, i_ymax = range(4, 9)  # column indices
2136    # sort by xmin values
2137    division_array = division_array[division_array[:, i_xmin].argsort()]
2138    intersections = []
2139    for i in range(n_divisions):
2140        x1, y1, x2, y2, id1, sl_xmin, sl_ymin, sl_xmax, sl_ymax = division_array[i, :]
2141        division1_vertices = [x1, y1, x2, y2]
2142        start = i + 1  # search should start from the next division
2143        # filter the array by checking if the divisions' bounding-boxes are
2144        # overlapping with the bounding-box of the current division
2145        candidates = division_array[start:, :][
2146            (
2147                (
2148                    (division_array[start:, i_xmax] >= sl_xmin)
2149                    & (division_array[start:, i_xmin] <= sl_xmax)
2150                )
2151                & (
2152                    (division_array[start:, i_ymax] >= sl_ymin)
2153                    & (division_array[start:, i_ymin] <= sl_ymax)
2154                )
2155            )
2156        ]
2157        for candid in candidates:
2158            id2 = candid[i_id]
2159            division2_vertices = candid[:4]
2160            if loom:
2161                x1, y1 = division1_vertices[:2]
2162                x2, y2 = division2_vertices[:2]
2163                if x1 == x2 or y1 == y2:
2164                    continue
2165            connection_type, x_point = intersection2(
2166                *division1_vertices, *division2_vertices
2167            )
2168            if connection_type not in [
2169                Connection.DISJOINT,
2170                Connection.NONE,
2171                Connection.PARALLEL,
2172            ]:
2173                division1, division2 = d_divisions[int(id1)], d_divisions[int(id2)]
2174                x_point__e1_2 = frozenset((division1.id, division2.id))
2175                if x_point__e1_2 not in d_connections:
2176                    inters_obj = Intersection(x_point, division1, division2)
2177                    d_intersections[inters_obj.id] = inters_obj
2178                    d_connections[x_point__e1_2] = inters_obj
2179                    division1.intersections.append(inters_obj)
2180                    division2.intersections.append(inters_obj)
2181                    intersections.append(inters_obj)
2182
2183    for division in division_list:
2184        division._sort_intersections()
2185    return intersections
2186
2187
2188def merge_nodes(
2189    division_list: list[Division],
2190    d_intersections: dict[int, Intersection],
2191    d_connections: dict[frozenset, Intersection],
2192    loom=False,
2193) -> list[Intersection]:
2194    """
2195    Find all intersections of the given divisions. Sweep-line algorithm
2196    without a self-balancing tree. Instead of a self-balancing tree,
2197    it uses a numpy array to sort and filter the divisions. For the
2198    number of divisions that are commonly needed in a lace, this is
2199    sufficiently fast. It is also more robust and much easier to
2200    understand and debug. Tested with tens of thousands of divisions but
2201    not millions. The book has a section on this algorithm.
2202    simetri.geometry.py has another version (called
2203    all_intersections) for finding intersections among a given
2204    list of divisions.
2205
2206    Arguments:
2207    ----------
2208        division_list: list of division objects.
2209
2210    Side Effects:
2211    -------------
2212        * Modifies the given division objects (in the division_list) in place
2213            by adding the intersections to the divisions' "intersections"
2214            attribute.
2215        * Updates the d_intersections
2216        * Updates the d_connections
2217
2218    Return:
2219    --------
2220        A list of all intersection objects among the given division
2221        list.
2222    """
2223    # register fake intersections at the endpoints of the open lines
2224    for division in division_list:
2225        if division.intersections:
2226            for x in division.intersections:
2227                d_intersections[x.id] = x
2228
2229    # All objects are assigned an integer id attribute when they are created
2230    division_array = array(
2231        [flatten(division.vertices) + [division.id] for division in division_list]
2232    )
2233    n_divisions = division_array.shape[0]  # number of divisions
2234    # precompute the min and max x and y values for each division
2235    # these will be used with the sweep line algorithm
2236    xmin = np.minimum(division_array[:, 0], division_array[:, 2]).reshape(
2237        n_divisions, 1
2238    )
2239    xmax = np.maximum(division_array[:, 0], division_array[:, 2]).reshape(
2240        n_divisions, 1
2241    )
2242    ymin = np.minimum(division_array[:, 1], division_array[:, 3]).reshape(
2243        n_divisions, 1
2244    )
2245    ymax = np.maximum(division_array[:, 1], division_array[:, 3]).reshape(
2246        n_divisions, 1
2247    )
2248    division_array = np.concatenate((division_array, xmin, ymin, xmax, ymax), 1)
2249    d_divisions = {}
2250    for division in division_list:
2251        d_divisions[division.id] = division
2252    i_id, i_xmin, i_ymin, i_xmax, i_ymax = range(4, 9)  # column indices
2253    # sort by xmin values
2254    division_array = division_array[division_array[:, i_xmin].argsort()]
2255    intersections = []
2256    for i in range(n_divisions):
2257        x1, y1, x2, y2, id1, sl_xmin, sl_ymin, sl_xmax, sl_ymax = division_array[i, :]
2258        division1_vertices = [x1, y1, x2, y2]
2259        start = i + 1  # search should start from the next division
2260        # filter the array by checking if the divisions' bounding-boxes are
2261        # overlapping with the bounding-box of the current division
2262        candidates = division_array[start:, :][
2263            (
2264                (
2265                    (division_array[start:, i_xmax] >= sl_xmin)
2266                    & (division_array[start:, i_xmin] <= sl_xmax)
2267                )
2268                & (
2269                    (division_array[start:, i_ymax] >= sl_ymin)
2270                    & (division_array[start:, i_ymin] <= sl_ymax)
2271                )
2272            )
2273        ]
2274        for candid in candidates:
2275            id2 = candid[i_id]
2276            division2_vertices = candid[:4]
2277            if loom:
2278                x1, y1 = division1_vertices[:2]
2279                x2, y2 = division2_vertices[:2]
2280                if x1 == x2 or y1 == y2:
2281                    continue
2282            connection_type, x_point = intersection2(
2283                *division1_vertices, *division2_vertices
2284            )
2285            if connection_type not in [
2286                Connection.DISJOINT,
2287                Connection.NONE,
2288                Connection.PARALLEL,
2289            ]:
2290                division1, division2 = d_divisions[int(id1)], d_divisions[int(id2)]
2291                x_point__e1_2 = frozenset((division1.id, division2.id))
2292                if x_point__e1_2 not in d_connections:
2293                    inters_obj = Intersection(x_point, division1, division2)
2294                    d_intersections[inters_obj.id] = inters_obj
2295                    d_connections[x_point__e1_2] = inters_obj
2296                    division1.intersections.append(inters_obj)
2297                    division2.intersections.append(inters_obj)
2298                    intersections.append(inters_obj)
2299
2300    for division in division_list:
2301        division._sort_intersections()
2302    return intersections
def array(unknown):

array(object, dtype=None, *, copy=True, order='K', subok=False, ndmin=0, like=None)

Create an array.

Parameters

object : array_like An array, any object exposing the array interface, an object whose __array__ method returns an array, or any (nested) sequence. If object is a scalar, a 0-dimensional array containing object is returned. dtype : data-type, optional The desired data-type for the array. If not given, NumPy will try to use a default dtype that can represent the values (by applying promotion rules when necessary.) copy : bool, optional If True (default), then the array data is copied. If None, a copy will only be made if __array__ returns a copy, if obj is a nested sequence, or if a copy is needed to satisfy any of the other requirements (dtype, order, etc.). Note that any copy of the data is shallow, i.e., for arrays with object dtype, the new array will point to the same objects. See Examples for ndarray.copy. For False it raises a ValueError if a copy cannot be avoided. Default: True. order : {'K', 'A', 'C', 'F'}, optional Specify the memory layout of the array. If object is not an array, the newly created array will be in C order (row major) unless 'F' is specified, in which case it will be in Fortran order (column major). If object is an array the following holds.

===== ========= ===================================================
order  no copy                     copy=True
===== ========= ===================================================
'K'   unchanged F & C order preserved, otherwise most similar order
'A'   unchanged F order if input is F and not C, otherwise C order
'C'   C order   C order
'F'   F order   F order
===== ========= ===================================================

When ``copy=None`` and a copy is made for other reasons, the result is
the same as if ``copy=True``, with some exceptions for 'A', see the
Notes section. The default order is 'K'.

subok : bool, optional If True, then sub-classes will be passed-through, otherwise the returned array will be forced to be a base-class array (default). ndmin : int, optional Specifies the minimum number of dimensions that the resulting array should have. Ones will be prepended to the shape as needed to meet this requirement. like : array_like, optional Reference object to allow the creation of arrays which are not NumPy arrays. If an array-like passed in as like supports the __array_function__ protocol, the result will be defined by it. In this case, it ensures the creation of an array object compatible with that passed in via this argument.

*New in version 1.20.0.*

Returns

out : ndarray An array object satisfying the specified requirements.

See Also

empty_like : Return an empty array with shape and type of input. ones_like : Return an array of ones with shape and type of input. zeros_like : Return an array of zeros with shape and type of input. full_like : Return a new array with shape of input filled with value. empty : Return a new uninitialized array. ones : Return a new array setting values to one. zeros : Return a new array setting values to zero. full : Return a new array of given shape filled with value. copy: Return an array copy of the given object.

Notes

When order is 'A' and object is an array in neither 'C' nor 'F' order, and a copy is forced by a change in dtype, then the order of the result is not necessarily 'C' as expected. This is likely a bug.

Examples

>>> np.array([1, 2, 3])
array([1, 2, 3])

Upcasting:

>>> np.array([1, 2, 3.0])
array([ 1.,  2.,  3.])

More than one dimension:

>>> np.array([[1, 2], [3, 4]])
array([[1, 2],
       [3, 4]])

Minimum dimensions 2:

>>> np.array([1, 2, 3], ndmin=2)
array([[1, 2, 3]])

Type provided:

>>> np.array([1, 2, 3], dtype=complex)
array([ 1.+0.j,  2.+0.j,  3.+0.j])

Data-type consisting of more than one element:

>>> x = np.array([(1,2),(3,4)],dtype=[('a','<i4'),('b','<i4')])
>>> x['a']
array([1, 3])

Creating an array from sub-classes:

>>> np.array(np.asmatrix('1 2; 3 4'))
array([[1, 2],
       [3, 4]])
>>> np.array(np.asmatrix('1 2; 3 4'), subok=True)
matrix([[1, 2],
        [3, 4]])
class Intersection(simetri.graphics.shape.Shape):
180class Intersection(Shape):
181    """Intersection of two divisions. They are at the endpoints of Section
182    objects. A division can have multiple sections and multiple
183    intersections. They can be located at the end of a division.
184
185    Args:
186        point (tuple): (x, y) coordinates of the intersection point.
187        division1 (Division): First division.
188        division2 (Division, optional): Second division. Defaults to None.
189        endpoint (bool, optional): If the intersection is at the end of a division, then endpoint is True. Defaults to False.
190        **kwargs: Additional attributes for cosmetic/drawing purposes.
191    """
192
193    def __init__(self, point: tuple, division1: "Division", division2: "Division" = None, endpoint: bool = False, **kwargs) -> None:
194        super().__init__([point], xform_matrix=None, subtype=Types.INTERSECTION, **kwargs)
195        self._point = point
196        self.division1 = division1
197        self.division2 = division2
198        self.overlap = None
199        self.endpoint = endpoint
200        self.division = None  # used for fragment divisions' DCEL structure
201
202        common_properties(self, id_only=True)
203
204    def _update(self, xform_matrix: array, reps=0):
205        """Update the transformation matrix of the intersection.
206
207        Args:
208            xform_matrix (array): Transformation matrix.
209            reps (int, optional): Number of repetitions. Defaults to 0.
210
211        Returns:
212            Any: Updated intersection or list of updated intersections.
213        """
214        if reps == 0:
215            self.xform_matrix = self.xform_matrix @ xform_matrix
216            res = self
217        else:
218            res = []
219            for _ in range(reps):
220                shape = self.copy()
221                shape._update(xform_matrix)
222                res.append(shape)
223
224        return res
225
226    def copy(self):
227        """Create a copy of the intersection.
228
229        Returns:
230            Intersection: A copy of the intersection.
231        """
232        intersection = Intersection(self.point, self.division1, self.division2)
233        for attrib in shape_style_map:
234            setattr(intersection, attrib, getattr(self, attrib))
235        custom_attribs = custom_attributes(self)
236        for attrib in custom_attribs:
237            setattr(intersection, attrib, getattr(self, attrib))
238        return intersection
239
240    @property
241    def point(self):
242        """Return the intersection point.
243
244        Returns:
245            list: Intersection point coordinates.
246        """
247        return list(np.array([*self._point, 1.0]) @ self.xform_matrix)[:2]
248
249    def __str__(self):
250        """String representation of the intersection.
251
252        Returns:
253            str: String representation.
254        """
255        return (
256            f"Intersection({[round(x, defaults['n_round']) for x in self.point]}, "
257            f"{tuple(list([self.division1, self.division2]))}"
258        )
259
260    def __repr__(self):
261        """String representation of the intersection.
262
263        Returns:
264            str: String representation.
265        """
266        return str(self)
267
268    def __eq__(self, other):
269        """Check if two intersections are equal.
270
271        Args:
272            other (Intersection): Another intersection.
273
274        Returns:
275            bool: True if equal, False otherwise.
276        """
277        return close_points2(self.point, other.point, dist2=defaults["dist_tol"] ** 2)

Intersection of two divisions. They are at the endpoints of Section objects. A division can have multiple sections and multiple intersections. They can be located at the end of a division.

Arguments:
  • point (tuple): (x, y) coordinates of the intersection point.
  • division1 (Division): First division.
  • division2 (Division, optional): Second division. Defaults to None.
  • endpoint (bool, optional): If the intersection is at the end of a division, then endpoint is True. Defaults to False.
  • **kwargs: Additional attributes for cosmetic/drawing purposes.
Intersection( point: tuple, division1: Division, division2: Division = None, endpoint: bool = False, **kwargs)
193    def __init__(self, point: tuple, division1: "Division", division2: "Division" = None, endpoint: bool = False, **kwargs) -> None:
194        super().__init__([point], xform_matrix=None, subtype=Types.INTERSECTION, **kwargs)
195        self._point = point
196        self.division1 = division1
197        self.division2 = division2
198        self.overlap = None
199        self.endpoint = endpoint
200        self.division = None  # used for fragment divisions' DCEL structure
201
202        common_properties(self, id_only=True)

Initialize a Shape object.

Arguments:
  • points (Sequence[Point], optional): The points that make up the shape.
  • closed (bool, optional): Whether the shape is closed. Defaults to False.
  • xform_matrix (np.array, optional): The transformation matrix. Defaults to None.
  • **kwargs (dict): Additional attributes for the shape.
Raises:
  • ValueError: If the provided subtype is not valid.
division1
division2
overlap
endpoint
division
def copy(self):
226    def copy(self):
227        """Create a copy of the intersection.
228
229        Returns:
230            Intersection: A copy of the intersection.
231        """
232        intersection = Intersection(self.point, self.division1, self.division2)
233        for attrib in shape_style_map:
234            setattr(intersection, attrib, getattr(self, attrib))
235        custom_attribs = custom_attributes(self)
236        for attrib in custom_attribs:
237            setattr(intersection, attrib, getattr(self, attrib))
238        return intersection

Create a copy of the intersection.

Returns:

Intersection: A copy of the intersection.

point
240    @property
241    def point(self):
242        """Return the intersection point.
243
244        Returns:
245            list: Intersection point coordinates.
246        """
247        return list(np.array([*self._point, 1.0]) @ self.xform_matrix)[:2]

Return the intersection point.

Returns:

list: Intersection point coordinates.

class Partition(simetri.graphics.shape.Shape):
280class Partition(Shape):
281    """These are the polygons of the non-interlaced geometry.
282    Fragments and partitions are scaled versions of each other.
283
284    Args:
285        points (list): List of points defining the partition.
286        **kwargs: Additional attributes for cosmetic/drawing purposes.
287    """
288
289    def __init__(self, points, **kwargs):
290        super().__init__(points, **kwargs)
291        self.subtype = Types.PART
292        self.area = polygon_area(self.vertices)
293        self.CG = polygon_cg(self.vertices)
294        common_properties(self)
295
296    def __str__(self):
297        """String representation of the partition.
298
299        Returns:
300            str: String representation.
301        """
302        return f"Part({self.vertices})"
303
304    def __repr__(self):
305        """String representation of the partition.
306
307        Returns:
308            str: String representation.
309        """
310        return self.__str__()

These are the polygons of the non-interlaced geometry. Fragments and partitions are scaled versions of each other.

Arguments:
  • points (list): List of points defining the partition.
  • **kwargs: Additional attributes for cosmetic/drawing purposes.
Partition(points, **kwargs)
289    def __init__(self, points, **kwargs):
290        super().__init__(points, **kwargs)
291        self.subtype = Types.PART
292        self.area = polygon_area(self.vertices)
293        self.CG = polygon_cg(self.vertices)
294        common_properties(self)

Initialize a Shape object.

Arguments:
  • points (Sequence[Point], optional): The points that make up the shape.
  • closed (bool, optional): Whether the shape is closed. Defaults to False.
  • xform_matrix (np.array, optional): The transformation matrix. Defaults to None.
  • **kwargs (dict): Additional attributes for the shape.
Raises:
  • ValueError: If the provided subtype is not valid.
subtype
area: float
672    @property
673    def area(self) -> float:
674        """Return the area of the shape.
675
676        Returns:
677            float: The area of the shape.
678        """
679        if self.closed:
680            vertices = self.vertices[:]
681            if not close_points2(vertices[0], vertices[-1], dist2=self.dist_tol2):
682                vertices = list(vertices) + [vertices[0]]
683            res = polygon_area(vertices)
684        else:
685            res = 0
686
687        return res

Return the area of the shape.

Returns:

float: The area of the shape.

CG
class Fragment(simetri.graphics.shape.Shape):
313class Fragment(Shape):
314    """A Fragment is a collection of section objects that are connected
315    to each other. These sections are already defined. They belong to
316    the polyline objects in a lace. Fragments can be open or closed.
317    They are created by the lace object.
318
319    Args:
320        points (list): List of points defining the fragment.
321        **kwargs: Additional attributes for cosmetic/drawing purposes.
322    """
323
324    def __init__(self, points, **kwargs):
325        super().__init__(points, **kwargs)
326        self.subtype = Types.FRAGMENT
327        self.area = polygon_area(self.vertices)
328        self.sections = []
329        self.intersections = []
330        self.inner_lines = []
331        self._divisions = []
332        self.CG = polygon_cg(self.vertices)
333        common_properties(self)
334
335    def __str__(self):
336        """String representation of the fragment.
337
338        Returns:
339            str: String representation.
340        """
341        return f"Fragment({self.vertices})"
342
343    def __repr__(self):
344        """String representation of the fragment.
345
346        Returns:
347            str: String representation.
348        """
349        return self.__str__()
350
351    @property
352    def divisions(self):
353        """Return the divisions of the fragment.
354
355        Returns:
356            list: List of divisions.
357        """
358        return self._divisions
359
360    @property
361    def center(self):
362        """Return the center of the fragment.
363
364        Returns:
365            list: Center coordinates.
366        """
367        return self.CG
368
369    def _set_divisions(self, dist_tol=None):
370        if dist_tol is None:
371            dist_tol = defaults["dist_tol"]
372        dist_tol2 = dist_tol * dist_tol  # squared distance tolerance
373        d_points__section = {}
374        for section in self.sections:
375            start = section.start.point
376            end = section.end.point
377            start = round(start[0], 2), round(start[1], 2)
378            end = round(end[0], 2), round(end[1], 2)
379            d_points__section[(start, end)] = section
380            d_points__section[(end, start)] = section
381
382        coord_pairs = connected_pairs(self.vertices)
383        self._divisions = []
384        for pair in coord_pairs:
385            x1, y1 = round_point(pair[0])
386            x2, y2 = round_point(pair[1])
387            division = Division((x1, y1), (x2, y2))
388            division.section = d_points__section[((x1, y1), (x2, y2))]
389            division.fragment = self
390            start_point = round_point(division.section.start.point)
391            end_point = round_point(division.section.end.point)
392            if close_points2(start_point, (x1, y1), dist2=dist_tol2):
393                division.intersections = [division.section.start, division.section.end]
394                division.section.start.division = division
395            elif close_points2(end_point, (x1, y1), dist2=dist_tol2):
396                division.intersections = [division.section.end, division.section.start]
397                division.section.end.division = division
398            else:
399                raise ValueError("Division does not match section")
400            self._divisions.append(division)
401        n = len(self._divisions)
402        for i, division in enumerate(self._divisions):
403            division.prev = self._divisions[i - 1]
404            division.next = self._divisions[(i + 1) % n]
405
406    def _set_twin_divisions(self):
407        for division in self.divisions:
408            section = division.section
409            if section.twin and section.twin.fragment:
410                twin_fragment = section.twin.fragment
411                distances = []
412                for _, division2 in enumerate(twin_fragment.divisions):
413                    dist = distance(
414                        division.section.mid_point, division2.section.mid_point
415                    )
416                    distances.append((dist, division2))
417                distances.sort(key=lambda x: x[0])
418                division.twin = distances[0][1]

A Fragment is a collection of section objects that are connected to each other. These sections are already defined. They belong to the polyline objects in a lace. Fragments can be open or closed. They are created by the lace object.

Arguments:
  • points (list): List of points defining the fragment.
  • **kwargs: Additional attributes for cosmetic/drawing purposes.
Fragment(points, **kwargs)
324    def __init__(self, points, **kwargs):
325        super().__init__(points, **kwargs)
326        self.subtype = Types.FRAGMENT
327        self.area = polygon_area(self.vertices)
328        self.sections = []
329        self.intersections = []
330        self.inner_lines = []
331        self._divisions = []
332        self.CG = polygon_cg(self.vertices)
333        common_properties(self)

Initialize a Shape object.

Arguments:
  • points (Sequence[Point], optional): The points that make up the shape.
  • closed (bool, optional): Whether the shape is closed. Defaults to False.
  • xform_matrix (np.array, optional): The transformation matrix. Defaults to None.
  • **kwargs (dict): Additional attributes for the shape.
Raises:
  • ValueError: If the provided subtype is not valid.
subtype
area: float
672    @property
673    def area(self) -> float:
674        """Return the area of the shape.
675
676        Returns:
677            float: The area of the shape.
678        """
679        if self.closed:
680            vertices = self.vertices[:]
681            if not close_points2(vertices[0], vertices[-1], dist2=self.dist_tol2):
682                vertices = list(vertices) + [vertices[0]]
683            res = polygon_area(vertices)
684        else:
685            res = 0
686
687        return res

Return the area of the shape.

Returns:

float: The area of the shape.

sections
intersections
inner_lines
CG
divisions
351    @property
352    def divisions(self):
353        """Return the divisions of the fragment.
354
355        Returns:
356            list: List of divisions.
357        """
358        return self._divisions

Return the divisions of the fragment.

Returns:

list: List of divisions.

center
360    @property
361    def center(self):
362        """Return the center of the fragment.
363
364        Returns:
365            list: Center coordinates.
366        """
367        return self.CG

Return the center of the fragment.

Returns:

list: Center coordinates.

class Section(simetri.graphics.shape.Shape):
421class Section(Shape):
422    """A section is a line segment between two intersections.
423    A division can have multiple sections. Sections are used to
424    draw the over/under plaits.
425
426    Args:
427        start (Intersection): Start intersection.
428        end (Intersection): End intersection.
429        is_overlap (bool, optional): If the section is an overlap. Defaults to False.
430        overlap (Overlap, optional): Overlap object. Defaults to None.
431        is_over (bool, optional): If the section is over. Defaults to False.
432        twin (Section, optional): Twin section. Defaults to None.
433        fragment (Fragment, optional): Fragment object. Defaults to None.
434        **kwargs: Additional attributes for cosmetic/drawing purposes.
435    """
436
437    def __init__(
438        self,
439        start: Intersection = None,
440        end: Intersection = None,
441        is_overlap: bool = False,
442        overlap: "Overlap" = None,
443        is_over: bool = False,
444        twin: "Section" = None,
445        fragment: "Fragment" = None,
446        **kwargs,
447    ):
448        super().__init__([start.point, end.point], subtype=Types.SECTION, **kwargs)
449        self.start = start
450        self.end = end
451        self.is_overlap = is_overlap
452        self.overlap = overlap
453        self.is_over = is_over
454        self.twin = twin
455        self.fragment = fragment
456        super().__init__([start.point, end.point], subtype=Types.SECTION, **kwargs)
457        self.length = distance(self.start.point, self.end.point)
458        self.mid_point = [
459            (self.start.point[0] + self.end.point[0]) / 2,
460            (self.start.point[1] + self.end.point[1]) / 2,
461        ]
462        common_properties(self)
463
464    def copy(self):
465        """Create a copy of the section.
466
467        Returns:
468            Section: A copy of the section.
469        """
470        overlap = self.overlap.copy() if self.overlap else None
471        start = self.start.copy()
472        end = self.end.copy()
473        section = Section(start, end, self.is_overlap, overlap, self.is_over)
474
475        return section
476
477    def end_point(self):
478        """Return the end point of the section.
479
480        Returns:
481            Intersection: End intersection.
482        """
483        if self.start.endpoint:
484            res = self.start
485        elif self.end.endpoint:
486            res = self.end
487        else:
488            res = None
489
490        return res
491
492    def __str__(self):
493        """String representation of the section.
494
495        Returns:
496            str: String representation.
497        """
498        return f"Section({self.start}, {self.end})"
499
500    def __repr__(self):
501        """String representation of the section.
502
503        Returns:
504            str: String representation.
505        """
506        return self.__str__()
507
508    @property
509    def is_endpoint(self):
510        """Return True if the section is an endpoint.
511
512        Returns:
513            bool: True if endpoint, False otherwise.
514        """
515        return self.start.endpoint or self.end.endpoint

A section is a line segment between two intersections. A division can have multiple sections. Sections are used to draw the over/under plaits.

Arguments:
  • start (Intersection): Start intersection.
  • end (Intersection): End intersection.
  • is_overlap (bool, optional): If the section is an overlap. Defaults to False.
  • overlap (Overlap, optional): Overlap object. Defaults to None.
  • is_over (bool, optional): If the section is over. Defaults to False.
  • twin (Section, optional): Twin section. Defaults to None.
  • fragment (Fragment, optional): Fragment object. Defaults to None.
  • **kwargs: Additional attributes for cosmetic/drawing purposes.
Section( start: Intersection = None, end: Intersection = None, is_overlap: bool = False, overlap: Overlap = None, is_over: bool = False, twin: Section = None, fragment: Fragment = None, **kwargs)
437    def __init__(
438        self,
439        start: Intersection = None,
440        end: Intersection = None,
441        is_overlap: bool = False,
442        overlap: "Overlap" = None,
443        is_over: bool = False,
444        twin: "Section" = None,
445        fragment: "Fragment" = None,
446        **kwargs,
447    ):
448        super().__init__([start.point, end.point], subtype=Types.SECTION, **kwargs)
449        self.start = start
450        self.end = end
451        self.is_overlap = is_overlap
452        self.overlap = overlap
453        self.is_over = is_over
454        self.twin = twin
455        self.fragment = fragment
456        super().__init__([start.point, end.point], subtype=Types.SECTION, **kwargs)
457        self.length = distance(self.start.point, self.end.point)
458        self.mid_point = [
459            (self.start.point[0] + self.end.point[0]) / 2,
460            (self.start.point[1] + self.end.point[1]) / 2,
461        ]
462        common_properties(self)

Initialize a Shape object.

Arguments:
  • points (Sequence[Point], optional): The points that make up the shape.
  • closed (bool, optional): Whether the shape is closed. Defaults to False.
  • xform_matrix (np.array, optional): The transformation matrix. Defaults to None.
  • **kwargs (dict): Additional attributes for the shape.
Raises:
  • ValueError: If the provided subtype is not valid.
start
end
is_overlap
overlap
is_over
twin
fragment
length
mid_point
def copy(self):
464    def copy(self):
465        """Create a copy of the section.
466
467        Returns:
468            Section: A copy of the section.
469        """
470        overlap = self.overlap.copy() if self.overlap else None
471        start = self.start.copy()
472        end = self.end.copy()
473        section = Section(start, end, self.is_overlap, overlap, self.is_over)
474
475        return section

Create a copy of the section.

Returns:

Section: A copy of the section.

def end_point(self):
477    def end_point(self):
478        """Return the end point of the section.
479
480        Returns:
481            Intersection: End intersection.
482        """
483        if self.start.endpoint:
484            res = self.start
485        elif self.end.endpoint:
486            res = self.end
487        else:
488            res = None
489
490        return res

Return the end point of the section.

Returns:

Intersection: End intersection.

is_endpoint
508    @property
509    def is_endpoint(self):
510        """Return True if the section is an endpoint.
511
512        Returns:
513            bool: True if endpoint, False otherwise.
514        """
515        return self.start.endpoint or self.end.endpoint

Return True if the section is an endpoint.

Returns:

bool: True if endpoint, False otherwise.

class Overlap(simetri.graphics.batch.Batch):
518class Overlap(Batch):
519    """An overlap is a collection of four connected sections.
520
521    Args:
522        intersections (list[Intersection], optional): List of intersections. Defaults to None.
523        sections (list[Section], optional): List of sections. Defaults to None.
524        visited (bool, optional): If the overlap is visited. Defaults to False.
525        drawable (bool, optional): If the overlap is drawable. Defaults to True.
526        **kwargs: Additional attributes for cosmetic/drawing purposes.
527    """
528
529    def __init__(
530        self,
531        intersections: list[Intersection] = None,
532        sections: list[Section] = None,
533        visited=False,
534        drawable=True,
535        **kwargs,
536    ):
537        self.intersections = intersections
538        self.sections = sections
539        super().__init__(sections, **kwargs)
540        self.subtype = Types.OVERLAP
541        self.visited = visited
542        self.drawable = drawable
543        common_properties(self)
544
545    def __str__(self):
546        """String representation of the overlap.
547
548        Returns:
549            str: String representation.
550        """
551        return f"Overlap({self.id})"
552
553    def __repr__(self):
554        """String representation of the overlap.
555
556        Returns:
557            str: String representation.
558        """
559        return f"Overlap({self.id})"

An overlap is a collection of four connected sections.

Arguments:
  • intersections (list[Intersection], optional): List of intersections. Defaults to None.
  • sections (list[Section], optional): List of sections. Defaults to None.
  • visited (bool, optional): If the overlap is visited. Defaults to False.
  • drawable (bool, optional): If the overlap is drawable. Defaults to True.
  • **kwargs: Additional attributes for cosmetic/drawing purposes.
Overlap( intersections: list[Intersection] = None, sections: list[Section] = None, visited=False, drawable=True, **kwargs)
529    def __init__(
530        self,
531        intersections: list[Intersection] = None,
532        sections: list[Section] = None,
533        visited=False,
534        drawable=True,
535        **kwargs,
536    ):
537        self.intersections = intersections
538        self.sections = sections
539        super().__init__(sections, **kwargs)
540        self.subtype = Types.OVERLAP
541        self.visited = visited
542        self.drawable = drawable
543        common_properties(self)

Initialize a Batch object.

Arguments:
  • elements (Sequence[Any], optional): The elements to include in the batch.
  • modifiers (Sequence[Modifier], optional): The modifiers to apply to the batch.
  • subtype (Types, optional): The subtype of the batch.
  • kwargs (dict): Additional keyword arguments.
intersections
sections
subtype
visited
drawable
class Division(simetri.graphics.shape.Shape):
562class Division(Shape):
563    """A division is a line segment between two intersections.
564
565    Args:
566        p1 (tuple): Start point.
567        p2 (tuple): End point.
568        xform_matrix (array, optional): Transformation matrix. Defaults to None.
569        **kwargs: Additional attributes for cosmetic/drawing purposes.
570    """
571
572    def __init__(self, p1, p2, xform_matrix=None, **kwargs):
573        super().__init__([p1, p2], subtype=Types.DIVISION, **kwargs)
574        self.p1 = p1
575        self.p2 = p2
576        self.intersections = []
577        self.twin = None  # used for fragment divisions only
578        self.section = None  # used for fragment divisions only
579        self.fragment = None  # used for fragment divisions only
580        self.next = None  # used for fragment divisions only
581        self.prev = None  # used for fragment divisions only
582        self.sections = []
583        super().__init__(
584            [p1, p2], subtype=Types.DIVISION, xform_matrix=xform_matrix, **kwargs
585        )
586        common_properties(self)
587
588    def _update(self, xform_matrix, reps=0):
589        """Update the transformation matrix of the division.
590
591        Args:
592            xform_matrix (array): Transformation matrix.
593            reps (int, optional): Number of repetitions. Defaults to 0.
594
595        Returns:
596            Any: Updated division or list of updated divisions.
597        """
598        if reps == 0:
599            self.xform_matrix = self.xform_matrix @ xform_matrix
600            res = self
601        else:
602            res = []
603            for _ in range(reps):
604                shape = self.copy()
605                shape._update(xform_matrix)
606                res.append(shape)
607
608        return res
609
610    def __str__(self):
611        """String representation of the division.
612
613        Returns:
614            str: String representation.
615        """
616        return (
617            f"Division(({self.p1[0]:.2f}, {self.p1[1]:.2f}), "
618            f"({self.p2[0]:.2f}, {self.p2[1]:.2f}))"
619        )
620
621    def __repr__(self):
622        """String representation of the division.
623
624        Returns:
625            str: String representation.
626        """
627        return (
628            f"Division(({self.p1[0]:.2f}, {self.p1[1]:.2f}), "
629            f"({self.p2[0]:.2f}, {self.p2[1]:.2f}))"
630        )
631
632    def copy(
633        self,
634        section: Section = None,
635        twin: Section = None,
636    ):
637        """Create a copy of the division.
638
639        Args:
640            section (Section, optional): Section object. Defaults to None.
641            twin (Section, optional): Twin section. Defaults to None.
642
643        Returns:
644            Division: A copy of the division.
645        """
646        division = Division(self.p1[:], self.p2[:], np.copy(self.xform_matrix))
647        for attrib in shape_style_map:
648            setattr(division, attrib, getattr(self, attrib))
649        division.twin = twin
650        division.section = section
651        division.fragment = self.fragment
652        division.next = self.next
653        division.prev = self.prev
654        division.sections = [x.copy() for x in self.sections]
655        custom_attributes_ = custom_attributes(self)
656        for attrib in custom_attributes_:
657            setattr(division, attrib, getattr(self, attrib))
658        return division
659
660    def _merged_sections(self):
661        """Merge sections of the division.
662
663        Returns:
664            list: List of merged sections.
665        """
666        chains = []
667        chain = [self.intersections[0]]
668        sections = self.sections[:]
669        for section in sections:
670            if not section.is_over:
671                if section.start.id == chain[-1].id:
672                    chain.append(section.end)
673                else:
674                    chains.append(chain)
675                    chain = [section.start, section.end]
676        if chain not in chains:
677            chains.append(chain)
678        return chains
679
680    def _sort_intersections(self) -> None:
681        """Sort intersections of the division."""
682        self.intersections.sort(key=lambda x: distance(self.p1, x.point))
683
684    def is_connected(self, other: "Division") -> bool:
685        """Return True if the division is connected to another division.
686
687        Args:
688            other (Division): Another division.
689
690        Returns:
691            bool: True if connected, False otherwise.
692        """
693        return self.p1 in other.end_points or self.p2 in other.end_points
694
695    @property
696    def end_points(self):
697        """Return the end points of the division.
698
699        Returns:
700            list: List of end points.
701        """
702        return [self.p1, self.p2]
703
704    @property
705    def start(self) -> Intersection:
706        """Return the start intersection of the division.
707
708        Returns:
709            Intersection: Start intersection.
710        """
711        return self.intersections[0]
712
713    @property
714    def end(self) -> Intersection:
715        """Return the end intersection of the division.
716
717        Returns:
718            Intersection: End intersection.
719        """
720        return self.intersections[-1]

A division is a line segment between two intersections.

Arguments:
  • p1 (tuple): Start point.
  • p2 (tuple): End point.
  • xform_matrix (array, optional): Transformation matrix. Defaults to None.
  • **kwargs: Additional attributes for cosmetic/drawing purposes.
Division(p1, p2, xform_matrix=None, **kwargs)
572    def __init__(self, p1, p2, xform_matrix=None, **kwargs):
573        super().__init__([p1, p2], subtype=Types.DIVISION, **kwargs)
574        self.p1 = p1
575        self.p2 = p2
576        self.intersections = []
577        self.twin = None  # used for fragment divisions only
578        self.section = None  # used for fragment divisions only
579        self.fragment = None  # used for fragment divisions only
580        self.next = None  # used for fragment divisions only
581        self.prev = None  # used for fragment divisions only
582        self.sections = []
583        super().__init__(
584            [p1, p2], subtype=Types.DIVISION, xform_matrix=xform_matrix, **kwargs
585        )
586        common_properties(self)

Initialize a Shape object.

Arguments:
  • points (Sequence[Point], optional): The points that make up the shape.
  • closed (bool, optional): Whether the shape is closed. Defaults to False.
  • xform_matrix (np.array, optional): The transformation matrix. Defaults to None.
  • **kwargs (dict): Additional attributes for the shape.
Raises:
  • ValueError: If the provided subtype is not valid.
p1
p2
intersections
twin
section
fragment
next
prev
sections
def copy( self, section: Section = None, twin: Section = None):
632    def copy(
633        self,
634        section: Section = None,
635        twin: Section = None,
636    ):
637        """Create a copy of the division.
638
639        Args:
640            section (Section, optional): Section object. Defaults to None.
641            twin (Section, optional): Twin section. Defaults to None.
642
643        Returns:
644            Division: A copy of the division.
645        """
646        division = Division(self.p1[:], self.p2[:], np.copy(self.xform_matrix))
647        for attrib in shape_style_map:
648            setattr(division, attrib, getattr(self, attrib))
649        division.twin = twin
650        division.section = section
651        division.fragment = self.fragment
652        division.next = self.next
653        division.prev = self.prev
654        division.sections = [x.copy() for x in self.sections]
655        custom_attributes_ = custom_attributes(self)
656        for attrib in custom_attributes_:
657            setattr(division, attrib, getattr(self, attrib))
658        return division

Create a copy of the division.

Arguments:
  • section (Section, optional): Section object. Defaults to None.
  • twin (Section, optional): Twin section. Defaults to None.
Returns:

Division: A copy of the division.

def is_connected(self, other: Division) -> bool:
684    def is_connected(self, other: "Division") -> bool:
685        """Return True if the division is connected to another division.
686
687        Args:
688            other (Division): Another division.
689
690        Returns:
691            bool: True if connected, False otherwise.
692        """
693        return self.p1 in other.end_points or self.p2 in other.end_points

Return True if the division is connected to another division.

Arguments:
  • other (Division): Another division.
Returns:

bool: True if connected, False otherwise.

end_points
695    @property
696    def end_points(self):
697        """Return the end points of the division.
698
699        Returns:
700            list: List of end points.
701        """
702        return [self.p1, self.p2]

Return the end points of the division.

Returns:

list: List of end points.

start: Intersection
704    @property
705    def start(self) -> Intersection:
706        """Return the start intersection of the division.
707
708        Returns:
709            Intersection: Start intersection.
710        """
711        return self.intersections[0]

Return the start intersection of the division.

Returns:

Intersection: Start intersection.

end: Intersection
713    @property
714    def end(self) -> Intersection:
715        """Return the end intersection of the division.
716
717        Returns:
718            Intersection: End intersection.
719        """
720        return self.intersections[-1]

Return the end intersection of the division.

Returns:

Intersection: End intersection.

class Polyline(simetri.graphics.shape.Shape):
723class Polyline(Shape):
724    """
725    Connected points, similar to Shape objects.
726    They can be closed or open.
727    They are defined by a sequence of points.
728    They have divisions, sections, and intersections.
729
730    Args:
731        points (list): List of points defining the polyline.
732        closed (bool, optional): If the polyline is closed. Defaults to True.
733        xform_matrix (array, optional): Transformation matrix. Defaults to None.
734        **kwargs: Additional attributes for cosmetic/drawing purposes.
735    """
736
737    def __init__(self, points, closed=True, xform_matrix=None, **kwargs):
738        self.__dict__["style"] = ShapeStyle()
739        self.__dict__["_style_map"] = shape_style_map
740        self._set_aliases()
741        self.closed = closed
742        kwargs["subtype"] = Types.POLYLINE
743        super().__init__(points, closed=closed, xform_matrix=xform_matrix, **kwargs)
744        self._set_divisions()
745        if not self.closed:
746            self._set_intersections()
747        common_properties(self)
748
749    def _update(self, xform_matrix, reps=0):
750        """Update the transformation matrix of the polyline.
751
752        Args:
753            xform_matrix (array): Transformation matrix.
754            reps (int, optional): Number of repetitions. Defaults to 0.
755
756        Returns:
757            Any: Updated polyline or list of updated polylines.
758        """
759        if reps == 0:
760            self.xform_matrix = self.xform_matrix @ xform_matrix
761            for division in self.divisions:
762                division._update(xform_matrix, reps=reps)
763            res = self
764        else:
765            res = []
766            for _ in range(reps):
767                shape = self.copy()
768                shape._update(xform_matrix)
769                res.append(shape)
770
771        return res
772
773    def __str__(self):
774        """String representation of the polyline.
775
776        Returns:
777            str: String representation.
778        """
779        return f"Polyline({self.final_coords[:, :2]})"
780
781    def __repr__(self):
782        """String representation of the polyline.
783
784        Returns:
785            str: String representation.
786        """
787        return self.__str__()
788
789    def iter_sections(self) -> Iterator:
790        """Iterate over the sections of the polyline.
791
792        Yields:
793            Section: Section object.
794        """
795        for division in self.divisions:
796            yield from division.sections
797
798    def iter_intersections(self):
799        """Iterate over the intersections of the polyline.
800
801        Yields:
802            Intersection: Intersection object.
803        """
804        for division in self.divisions:
805            yield from division.intersections
806
807    @property
808    def intersections(self):
809        """Return the intersections of the polyline.
810
811        Returns:
812            list: List of intersections.
813        """
814        res = []
815        for division in self.divisions:
816            res.extend(division.intersections)
817        return res
818
819    @property
820    def area(self):
821        """Return the area of the polygon.
822
823        Returns:
824            float: Area of the polygon.
825        """
826        return polygon_area(self.vertices)
827
828    @property
829    def sections(self):
830        """Return the sections of the polyline.
831
832        Returns:
833            list: List of sections.
834        """
835        sections = []
836        for division in self.divisions:
837            sections.extend(division.sections)
838        return sections
839
840    @property
841    def divisions(self):
842        """Return the divisions of the polyline.
843
844        Returns:
845            list: List of divisions.
846        """
847        return self.__dict__["divisions"]
848
849    def _set_divisions(self):
850        vertices = self.vertices
851        if self.closed:
852            vertices = list(vertices) + [vertices[0]]
853        pairs = connected_pairs(vertices)
854        divisions = [Division(p1, p2) for p1, p2 in pairs]
855        self.__dict__["divisions"] = divisions
856
857    def _set_intersections(self):
858        """Fake intersections for open lines."""
859        division1 = self.divisions[0]
860        division2 = self.divisions[-1]
861        x1 = Intersection(division1.p1, division1, None, True)
862        division1.intersections = [x1]
863        x2 = Intersection(division2.p2, division2, None, True)
864        if division1.id == division2.id:
865            division1.intersections.append(x2)
866        else:
867            division2.intersections = [x2]

Connected points, similar to Shape objects. They can be closed or open. They are defined by a sequence of points. They have divisions, sections, and intersections.

Arguments:
  • points (list): List of points defining the polyline.
  • closed (bool, optional): If the polyline is closed. Defaults to True.
  • xform_matrix (array, optional): Transformation matrix. Defaults to None.
  • **kwargs: Additional attributes for cosmetic/drawing purposes.
Polyline(points, closed=True, xform_matrix=None, **kwargs)
737    def __init__(self, points, closed=True, xform_matrix=None, **kwargs):
738        self.__dict__["style"] = ShapeStyle()
739        self.__dict__["_style_map"] = shape_style_map
740        self._set_aliases()
741        self.closed = closed
742        kwargs["subtype"] = Types.POLYLINE
743        super().__init__(points, closed=closed, xform_matrix=xform_matrix, **kwargs)
744        self._set_divisions()
745        if not self.closed:
746            self._set_intersections()
747        common_properties(self)

Initialize a Shape object.

Arguments:
  • points (Sequence[Point], optional): The points that make up the shape.
  • closed (bool, optional): Whether the shape is closed. Defaults to False.
  • xform_matrix (np.array, optional): The transformation matrix. Defaults to None.
  • **kwargs (dict): Additional attributes for the shape.
Raises:
  • ValueError: If the provided subtype is not valid.
closed
def iter_sections(self) -> Iterator:
789    def iter_sections(self) -> Iterator:
790        """Iterate over the sections of the polyline.
791
792        Yields:
793            Section: Section object.
794        """
795        for division in self.divisions:
796            yield from division.sections

Iterate over the sections of the polyline.

Yields:

Section: Section object.

def iter_intersections(self):
798    def iter_intersections(self):
799        """Iterate over the intersections of the polyline.
800
801        Yields:
802            Intersection: Intersection object.
803        """
804        for division in self.divisions:
805            yield from division.intersections

Iterate over the intersections of the polyline.

Yields:

Intersection: Intersection object.

intersections
807    @property
808    def intersections(self):
809        """Return the intersections of the polyline.
810
811        Returns:
812            list: List of intersections.
813        """
814        res = []
815        for division in self.divisions:
816            res.extend(division.intersections)
817        return res

Return the intersections of the polyline.

Returns:

list: List of intersections.

area
819    @property
820    def area(self):
821        """Return the area of the polygon.
822
823        Returns:
824            float: Area of the polygon.
825        """
826        return polygon_area(self.vertices)

Return the area of the polygon.

Returns:

float: Area of the polygon.

sections
828    @property
829    def sections(self):
830        """Return the sections of the polyline.
831
832        Returns:
833            list: List of sections.
834        """
835        sections = []
836        for division in self.divisions:
837            sections.extend(division.sections)
838        return sections

Return the sections of the polyline.

Returns:

list: List of sections.

divisions
840    @property
841    def divisions(self):
842        """Return the divisions of the polyline.
843
844        Returns:
845            list: List of divisions.
846        """
847        return self.__dict__["divisions"]

Return the divisions of the polyline.

Returns:

list: List of divisions.

class ParallelPolyline(simetri.graphics.batch.Batch):
870class ParallelPolyline(Batch):
871    """A ParallelPolylines is a collection of parallel Polylines.
872    They are defined by a main polyline and a list of offset
873    values (that can be negative or positive).
874
875    Args:
876        polyline (Polyline): Main polyline.
877        offset (float): Offset value.
878        lace (Lace): Lace object.
879        under (bool, optional): If the polyline is under. Defaults to False.
880        closed (bool, optional): If the polyline is closed. Defaults to True.
881        dist_tol (float, optional): Distance tolerance. Defaults to None.
882        **kwargs: Additional attributes for cosmetic/drawing purposes.
883    """
884
885    def __init__(
886        self,
887        polyline,
888        offset,
889        lace,
890        under=False,
891        closed=True,
892        dist_tol=None,
893        **kwargs,
894    ):
895        if dist_tol is None:
896            dist_tol = defaults["dist_tol"]
897        dist_tol2 = dist_tol * dist_tol
898        self.polyline = polyline
899        self.offset = offset
900        self.dist_tol = dist_tol
901        self.dist_tol2 = dist_tol2
902        self.closed = closed
903        self._set_offset_polylines()
904        self.polyline_list = [self.polyline] + self.offset_poly_list
905        super().__init__(self.polyline_list, **kwargs)
906        self.subtype = Types.PARALLEL_POLYLINE
907        self.overlaps = None
908        self.under = under
909        common_properties(self)
910
911    @property
912    def sections(self) -> List[Section]:
913        """Return the sections of the parallel polyline.
914
915        Returns:
916            list: List of sections.
917        """
918        sects = []
919        for polyline in self.polyline_list:
920            sects.extend(polyline.sections)
921        return sects
922
923    def _set_offset_polylines(self):
924        polyline = self.polyline
925        if self.closed:
926            vertices = list(polyline.vertices)
927            vertices = vertices + [vertices[0]]
928            offset_polygons = double_offset_polygons(
929                vertices, self.offset, dist_tol=self.dist_tol)
930        else:
931            offset_polylines = double_offset_polylines(polyline.vertices, self.offset)
932        polylines = []
933        if self.closed:
934            for polygon in offset_polygons:
935                polylines.append(Polyline(polygon, closed=self.closed))
936        else:
937            for polyline in offset_polylines:
938                polylines.append(Polyline(polyline, closed=self.closed))
939
940        self.offset_poly_list = polylines

A ParallelPolylines is a collection of parallel Polylines. They are defined by a main polyline and a list of offset values (that can be negative or positive).

Arguments:
  • polyline (Polyline): Main polyline.
  • offset (float): Offset value.
  • lace (Lace): Lace object.
  • under (bool, optional): If the polyline is under. Defaults to False.
  • closed (bool, optional): If the polyline is closed. Defaults to True.
  • dist_tol (float, optional): Distance tolerance. Defaults to None.
  • **kwargs: Additional attributes for cosmetic/drawing purposes.
ParallelPolyline( polyline, offset, lace, under=False, closed=True, dist_tol=None, **kwargs)
885    def __init__(
886        self,
887        polyline,
888        offset,
889        lace,
890        under=False,
891        closed=True,
892        dist_tol=None,
893        **kwargs,
894    ):
895        if dist_tol is None:
896            dist_tol = defaults["dist_tol"]
897        dist_tol2 = dist_tol * dist_tol
898        self.polyline = polyline
899        self.offset = offset
900        self.dist_tol = dist_tol
901        self.dist_tol2 = dist_tol2
902        self.closed = closed
903        self._set_offset_polylines()
904        self.polyline_list = [self.polyline] + self.offset_poly_list
905        super().__init__(self.polyline_list, **kwargs)
906        self.subtype = Types.PARALLEL_POLYLINE
907        self.overlaps = None
908        self.under = under
909        common_properties(self)

Initialize a Batch object.

Arguments:
  • elements (Sequence[Any], optional): The elements to include in the batch.
  • modifiers (Sequence[Modifier], optional): The modifiers to apply to the batch.
  • subtype (Types, optional): The subtype of the batch.
  • kwargs (dict): Additional keyword arguments.
polyline
offset
dist_tol
dist_tol2
closed
polyline_list
subtype
overlaps
under
sections: List[Section]
911    @property
912    def sections(self) -> List[Section]:
913        """Return the sections of the parallel polyline.
914
915        Returns:
916            list: List of sections.
917        """
918        sects = []
919        for polyline in self.polyline_list:
920            sects.extend(polyline.sections)
921        return sects

Return the sections of the parallel polyline.

Returns:

list: List of sections.

class Lace(simetri.graphics.batch.Batch):
 943class Lace(Batch):
 944    """
 945    A Lace is a collection of ParallelPolylines objects.
 946    They are used to create interlace patterns.
 947
 948    Args:
 949        polygon_shapes (Union[Batch, list[Shape]], optional): List of polygon shapes. Defaults to None.
 950        polyline_shapes (Union[Batch, list[Shape]], optional): List of polyline shapes. Defaults to None.
 951        offset (float, optional): Offset value. Defaults to 2.
 952        rtol (float, optional): Relative tolerance. Defaults to None.
 953        swatch (list, optional): Swatch list. Defaults to None.
 954        breakpoints (list, optional): Breakpoints list. Defaults to None.
 955        plait_color (colors.Color, optional): Plait color. Defaults to None.
 956        draw_fragments (bool, optional): If fragments should be drawn. Defaults to True.
 957        palette (list, optional): Palette list. Defaults to None.
 958        color_step (int, optional): Color step. Defaults to 1.
 959        with_plaits (bool, optional): If plaits should be included. Defaults to True.
 960        area_threshold (float, optional): Area threshold. Defaults to None.
 961        radius_threshold (float, optional): Radius threshold. Defaults to None.
 962        **kwargs: Additional attributes for cosmetic/drawing purposes.
 963    """
 964
 965    # @timing
 966    def __init__(
 967        self,
 968        polygon_shapes: Union[Batch, list[Shape]] = None,
 969        polyline_shapes: Union[Batch, list[Shape]] = None,
 970        offset: float = 2,
 971        rtol: float = None,
 972        swatch: list = None,
 973        breakpoints: list = None,
 974        plait_color: colors.Color = None,
 975        draw_fragments: bool = True,
 976        palette: list = None,
 977        color_step: int = 1,
 978        with_plaits: bool = True,
 979        area_threshold: float = None,
 980        radius_threshold: float = None,
 981        **kwargs,
 982    ) -> None:
 983        validate_args(kwargs, shape_style_map)
 984        (
 985            rtol,
 986            swatch,
 987            plait_color,
 988            draw_fragments,
 989            area_threshold,
 990            radius_threshold,
 991        ) = get_defaults(
 992            [
 993                "rtol",
 994                "swatch",
 995                "plait_color",
 996                "draw_fragments",
 997                "area_threshold",
 998                "radius_threshold",
 999            ],
1000            [
1001                rtol,
1002                swatch,
1003                plait_color,
1004                draw_fragments,
1005                area_threshold,
1006                radius_threshold,
1007            ],
1008        )
1009        if polygon_shapes:
1010            polygon_shapes = polygon_shapes.merge_shapes()
1011            self.polygon_shapes = self._check_polygons(polygon_shapes)
1012        else:
1013            self.polygon_shapes = []
1014        if polyline_shapes:
1015            polyline_shapes = polyline_shapes.merge_shapes()
1016            self.polyline_shapes = self._check_polylines(polyline_shapes)
1017        else:
1018            self.polyline_shapes = []
1019        if not self.polygon_shapes and not self.polyline_shapes:
1020            msg = "Lace.__init__ : No polygons or polylines found."
1021            raise ValueError(msg)
1022        self.polyline_shapes = polyline_shapes
1023        self.offset = offset
1024        self.main_intersections = None
1025        self.offset_intersections = None
1026        self.xform_matrix = np.eye(3)
1027        self.rtol = rtol
1028        self.swatch = swatch
1029        self.breakpoints = breakpoints
1030        self.plait_color = plait_color
1031        self.draw_fragments = draw_fragments
1032        self.palette = palette
1033        self.color_step = color_step
1034        self.with_plaits = with_plaits
1035        self.d_intersections = {}  # key, value:intersection.id, intersection
1036        self.d_connections = {}
1037        self.plaits = []
1038        self.overlaps = []
1039        self._groups = None
1040        self.area_threshold = area_threshold
1041        self.radius_threshold = radius_threshold
1042        if kwargs and "_copy" in kwargs:
1043            # pass the pre-computed values
1044            for k, v in kwargs:
1045                if k == "_copy":
1046                    continue
1047                else:
1048                    setattr(self, k, v)
1049        else:
1050            self._set_polyline_list()  # main divisions are set here along with polylines
1051            # polyline.divisions is the list of Division objects
1052            self._set_parallel_poly_list()
1053            # start_time2 = time.perf_counter()
1054            self._set_intersections()
1055            # end_time2 = time.perf_counter()
1056            # print(
1057            #     f"Lace.__init__ intersections computed in {end_time2 - start_time2:0.4f} seconds"
1058            # )
1059
1060            self._set_overlaps()
1061            self._set_twin_sections()
1062            self._set_fragments()
1063            if not self.polyline_shapes:
1064                self._set_outline()
1065            # self._set_partitions()
1066            self._set_over_under()
1067            if self.with_plaits:
1068                self.set_plaits()
1069            # self._set_convex_hull()
1070            # self._set_concave_hull()
1071            # self._set_fragment_groups()
1072            # self._set_partition_groups()
1073            self._b_box = None
1074        elements = [polyline for polyline in self.parallel_poly_list] + self.fragments
1075
1076        if "debug" in kwargs:
1077            kwargs.pop("debug")
1078        super().__init__(elements, **kwargs)
1079        if kwargs and "_copy" not in kwargs:
1080            for k, v in kwargs.items():
1081                if k in shape_style_map:
1082                    setattr(self, k, v)  # todo: we should check for valid values here
1083                else:
1084                    raise AttributeError(f"{k}. Invalid attribute!")
1085        self.subtype = Types.LACE
1086        common_properties(self)
1087
1088    @property
1089    def center(self):
1090        """Return the center of the lace.
1091
1092        Returns:
1093            list: Center coordinates.
1094        """
1095        return self.outline.CG
1096
1097    @property
1098    def fragment_groups(self):
1099        """Return the fragment groups of the lace.
1100
1101        Returns:
1102            dict: Dictionary of fragment groups.
1103        """
1104        center = self.center
1105        radius_frag = []
1106        for fragment in self.fragments:
1107            radius = int(distance(center, fragment.CG))
1108            for rad, frag in radius_frag:
1109                if abs(radius - rad) <= 2:
1110                    radius = rad
1111                    break
1112            radius_frag.append((radius, fragment))
1113
1114        radius_frag.sort(key=lambda x: x[0])
1115
1116        d_groups = {}
1117        for i, (radius, fragment) in enumerate(radius_frag):
1118            if i == 0:
1119                d_groups[radius] = [fragment]
1120            else:
1121                if radius in d_groups:
1122                    d_groups[radius].append(fragment)
1123                else:
1124                    d_groups[radius] = [fragment]
1125
1126        return d_groups
1127
1128    def _check_polygons(self, polygon_shapes):
1129        if isinstance(polygon_shapes, Batch):
1130            polygon_shapes = polygon_shapes.all_shapes
1131        for polygon in polygon_shapes:
1132            if len(polygon.primary_points) < 3:
1133                msg = "Lace.__init__ found polygon with less than 3 points."
1134                raise ValueError(msg)
1135            if not polygon.closed:
1136                msg = "Lace.__init__ : Invalid polygons"
1137                raise ValueError(msg)
1138            if polygon.primary_points[0] != polygon.primary_points[-1]:
1139                polygon.primary_points.append(polygon.primary_points[0])
1140        # check if the polygons are clockwise
1141        for polygon in polygon_shapes:
1142            if not right_handed(polygon.vertices):
1143                polygon.primary_points.reverse()
1144
1145        return polygon_shapes
1146
1147    def _check_polylines(self, polyline_shapes):
1148        if isinstance(polyline_shapes, Batch):
1149            polyline_shapes = polyline_shapes.all_shapes
1150        for polyline in polyline_shapes:
1151            if len(polyline.primary_points) < 2:
1152                msg = "Lace.__init__ found polyline with less than 2 points."
1153                raise ValueError(msg)
1154
1155        return polyline_shapes
1156
1157    def _update(self, xform_matrix, reps=0):
1158        """Update the transformation matrix of the lace.
1159
1160        Args:
1161            xform_matrix (array): Transformation matrix.
1162            reps (int, optional): Number of repetitions. Defaults to 0.
1163
1164        Returns:
1165            Any: Updated lace or list of updated laces.
1166        """
1167        if reps == 0:
1168            self.xform_matrix = self.xform_matrix @ xform_matrix
1169            for polygon in self.polygon_shapes:
1170                polygon._update(xform_matrix)
1171
1172            if self.polyline_shapes:
1173                for polyline in self.polyline_shapes:
1174                    polyline._update(xform_matrix)
1175
1176            for polyline in self.parallel_poly_list:
1177                polyline._update(xform_matrix)
1178
1179            for fragment in self.fragments:
1180                fragment._update(xform_matrix)
1181
1182            for intersection in self.main_intersections:
1183                intersection._update(xform_matrix)
1184
1185            for intersection in self.offset_intersections:
1186                intersection._update(xform_matrix)
1187
1188            for overlap in self.overlaps:
1189                overlap._update(xform_matrix)
1190
1191            for plait in self.plaits:
1192                plait._update(xform_matrix)
1193
1194            return self
1195        else:
1196            res = []
1197            for _ in range(reps):
1198                shape = self.copy()
1199                shape._update(xform_matrix)
1200                res.append(shape)
1201            return res
1202
1203    # @timing
1204    def _set_twin_sections(self):
1205        for par_poly in self.parallel_poly_list:
1206            poly1, poly2 = par_poly.offset_poly_list
1207            for i, sec in enumerate(poly1.iter_sections()):
1208                sec1 = sec
1209                sec2 = poly2.sections[i]
1210                sec1.twin = sec2
1211                sec2.twin = sec1
1212
1213    # @timing
1214    def _set_partitions(self):
1215        for fragment in self.fragments:
1216            self.partitions = []
1217            for fragment in self.fragments:
1218                partition = Shape(offset_polygon(fragment.vertices, self.offset))
1219                self.partitions.append(partition)
1220
1221    # To do: This doesn't work if we have polyline shapes!
1222    def _set_outline(self):
1223        # outline is a special fragment that covers the whole lace
1224        areas = []
1225        for fragment in self.fragments:
1226            areas.append((fragment.area, fragment))
1227        areas.sort(reverse=True, key=lambda x: x[0])
1228        self.outline = areas[0][1]
1229        self.fragments.remove(self.outline)
1230        # perimenter is the outline of the partitions
1231        self.perimeter = Shape(offset_polygon(self.outline.vertices, -self.offset))
1232        # skeleton is the input polylines that the lace is based on
1233        self.skeleton = Batch(self.polyline_list)
1234
1235    def set_fragment_groups(self):
1236        # to do : handle repeated code. same in _set_partition_groups
1237        areas = []
1238        for i, fragment in enumerate(self.fragments):
1239            areas.append((fragment.area, i))
1240        areas.sort()
1241        bins = group_into_bins(areas, self.area_threshold)
1242        self.fragments_by_area = OrderedDict()
1243        for i, bin in enumerate(bins):
1244            area_values = [x[0] for x in bin]
1245            key = sum([x[0] for x in bin]) / len(bin)
1246            fragments = []
1247            for area, ind in areas:
1248                if area in area_values:
1249                    fragments.append(self.fragments[ind])
1250            self.fragments_by_area[key] = fragments
1251
1252        radii = []
1253        for i, fragment in enumerate(self.fragments):
1254            radii.append((distance(self.center, fragment.CG), i))
1255        radii.sort()
1256        bins = group_into_bins(radii, self.radius_threshold)
1257        self.fragments_by_radius = OrderedDict()
1258        for i, bin in enumerate(bins):
1259            radius_values = [x[0] for x in bin]
1260            key = sum([x[0] for x in bin]) / len(bin)
1261            fragments = []
1262            for radius, ind in radii:
1263                if radius in radius_values:
1264                    fragments.append(self.fragments[ind])
1265            self.fragments_by_radius[key] = fragments
1266
1267    # @timing
1268    def _set_partition_groups(self):
1269        # to do : handle repeated code. same in set_fragment_groups
1270        areas = []
1271        for i, partition in enumerate(self.partitions):
1272            areas.append((partition.area, i))
1273        areas.sort()
1274        bins = group_into_bins(areas, self.area_threshold)
1275        self.partitions_by_area = OrderedDict()
1276        for i, bin_ in enumerate(bins):
1277            area_values = [x[0] for x in bin_]
1278            key = sum([x[0] for x in bin_]) / len(bin_)
1279            partitions = []
1280            for area, ind in areas:
1281                if area in area_values:
1282                    partitions.append(self.partitions[ind])
1283            self.partitions_by_area[key] = partitions
1284
1285        radii = []
1286        for i, partition in enumerate(self.partitions):
1287            CG = polygon_center(partition.vertices)
1288            radii.append((distance(self.center, CG), i))
1289        radii.sort()
1290        bins = group_into_bins(radii, self.radius_threshold)
1291        self.partitions_by_radius = OrderedDict()
1292        for i, bin_ in enumerate(bins):
1293            radius_values = [x[0] for x in bin_]
1294            key = sum([x[0] for x in bin_]) / len(bin_)
1295            partitions = []
1296            for radius, ind in radii:
1297                if radius in radius_values:
1298                    partitions.append(self.partitions[ind])
1299            self.partitions_by_radius[key] = partitions
1300
1301    # @timing
1302    def _set_fragments(self):
1303        G = nx.Graph()
1304        for section in self.iter_offset_sections():
1305            if section.is_overlap:
1306                continue
1307            G.add_edge(section.start.id, section.end.id, section=section)
1308
1309        cycles = nx.cycle_basis(G)
1310        fragments = []
1311        d_x = self.d_intersections
1312        for cycle in cycles:
1313            cycle.append(cycle[0])
1314            nodes = cycle
1315            edges = connected_pairs(cycle)
1316            sections = [G.edges[edge]["section"] for edge in edges]
1317            s_intersections = set()
1318            for section in sections:
1319                s_intersections.add(section.start.id)
1320                s_intersections.add(section.end.id)
1321            intersections = [self.d_intersections[i] for i in s_intersections]
1322            points = [d_x[x_id].point for x_id in nodes]
1323            if not right_handed(points):
1324                points.reverse()
1325            fragment = Fragment(points)
1326            fragment.sections = sections
1327            fragment.intersections = intersections
1328            fragments.append(fragment)
1329
1330        for fragment in fragments:
1331            for section in fragment.sections:
1332                section.fragment = fragment
1333
1334        for fragment in fragments:
1335            fragment._set_divisions()
1336        for fragment in fragments:
1337            fragment._set_twin_divisions()
1338
1339        self.fragments = fragments
1340
1341    def _set_concave_hull(self):
1342        self.concave_hull = self.outline.vertices
1343
1344    def _set_convex_hull(self):
1345        self.convex_hull = convex_hull(self.outline.vertices)
1346
1347    def copy(self):
1348        class Dummy(Lace):
1349            pass
1350
1351        # we need to copy the polyline_list and parallel_poly_list
1352        for polyline in self.polyline_list:
1353            polyline.copy()
1354
1355    def get_sketch(self):
1356        """
1357        Create and return a Sketch object. Sketch is a Batch object
1358        with Shape elements corresponding to the vertices of the plaits
1359        and fragments of the Lace instance. They have 'plaits' and
1360        'fragments' attributes to hold lists of Shape objects populated
1361        with plait and fragment vertices of the Lace instance
1362        respectively. They are used for drawing multiple copies of the
1363        original lace pattern. They are light-weight compared to the
1364        Lace objects since they only contain sufficient data to draw the
1365        lace objects. Hundreds of these objects can be used to create
1366        wallpaper patterns or other patterns without having to contain
1367        unnecessary data. They do not share points with the original
1368        Lace object.
1369
1370        Arguments:
1371        ----------
1372            None
1373
1374        Prerequisites:
1375        --------------
1376            * A lace object to be copied.
1377
1378        Side effects:
1379        -------------
1380            None
1381
1382        Return:
1383        --------
1384            A Sketch object.
1385        """
1386        fragments = []
1387        for fragment in self.fragments:
1388            polygon = Shape(fragment.vertices)
1389            polygon.fill = True
1390            polygon.subtype = Types.FRAGMENT
1391            fragments.append(polygon)
1392
1393        plaits = []
1394        for plait in self.plaits:
1395            polygon = Shape(plait.vertices)
1396            polygon.fill = True
1397            polygon.subtype = Types.PLAIT
1398            plaits.append(polygon)
1399
1400        sketch = Batch((fragments + plaits))
1401        sketch.fragments = fragments
1402        sketch.plaits = plaits
1403        sketch.outline = self.outline
1404        sketch.subtype = Types.SKETCH
1405
1406        sketch.draw_plaits = True
1407        sketch.draw_fragments = True
1408        return sketch
1409
1410    def group_fragments(self, tol=None):
1411        """Group the fragments by the number of vertices and the area.
1412
1413        Args:
1414            tol (float, optional): Tolerance value. Defaults to None.
1415
1416        Returns:
1417            list: List of grouped fragments.
1418        """
1419        if tol is None:
1420            tol = defaults["tol"]
1421        frags = self.fragments
1422        vert_groups = [
1423            [frag for frag in frags if len(frag.vertices) == n]
1424            for n in set([len(f.vertices) for f in frags])
1425        ]
1426        groups = []
1427        for group in vert_groups:
1428            areas = [
1429                [f for f in group if isclose(f.area, area, rtol=tol)]
1430                for area in set([frag.area for frag in group])
1431            ]
1432            areas.sort(key=lambda x: x[0].area, reverse=True)
1433            groups.append(areas)
1434        groups.sort(key=lambda x: x[0][0].area, reverse=True)
1435
1436        return groups
1437
1438    def get_fragment_cycles(self):
1439        """
1440        Iterate over the offset sections and create a graph of the
1441        intersections (start and end of the sections). Then find the
1442        cycles in the graph. self.d_intersections is used to map
1443        the graph nodes to the actual intersection points.
1444
1445        Returns:
1446            list: List of fragment cycles.
1447        """
1448        graph_edges = []
1449        for section in self.iter_offset_sections():
1450            if section.is_overlap:
1451                continue
1452            graph_edges.append((section.start.id, section.end.id))
1453
1454        return get_cycles(graph_edges)
1455
1456    def _set_inner_lines(self, item, n, offset, line_color=colors.blue, line_width=1):
1457        for i in range(n):
1458            vertices = item.vertices
1459            dist_tol = defaults["dist_tol"]
1460            offset_poly = offset_polygon_points(
1461                vertices, offset * (i + 1), dist_tol=dist_tol
1462            )
1463            shape = Shape(offset_poly)
1464            shape.fill = False
1465            shape.line_width = line_width
1466            shape.line_color = line_color
1467            item.inner_lines.append(shape)
1468
1469    def set_plait_lines(self, n, offset, line_color=colors.blue, line_width=1):
1470        """Create offset lines inside the plaits of the lace.
1471
1472        Args:
1473            n (int): Number of lines.
1474            offset (float): Offset value.
1475            line_color (colors.Color, optional): Line color. Defaults to colors.blue.
1476            line_width (int, optional): Line width. Defaults to 1.
1477        """
1478        for plait in self.plaits:
1479            plait.inner_lines = []
1480            self._set_inner_lines(plait, n, offset, line_color, line_width)
1481
1482    def set_fragment_lines(
1483        self,
1484        n: int,
1485        offset: float,
1486        line_color: colors.Color = colors.blue,
1487        line_width=1,
1488    ) -> None:
1489        """
1490        Create offset lines inside the fragments of the lace.
1491
1492        Args:
1493            n (int): Number of lines.
1494            offset (float): Offset value.
1495            line_color (colors.Color, optional): Line color. Defaults to colors.blue.
1496            line_width (int, optional): Line width. Defaults to 1.
1497        """
1498        for fragment in self.fragments:
1499            fragment.inner_lines = []
1500            self._set_inner_lines(fragment, n, offset, line_color, line_width)
1501
1502    @property
1503    def all_divisions(self) -> List:
1504        """
1505        Return a list of all the divisions (both main and offset) in the lace.
1506
1507        Returns:
1508            list: List of all divisions.
1509        """
1510        res = []
1511        for parallel_polyline in self.parallel_poly_list:
1512            for polyline in parallel_polyline.polyline_list:
1513                res.extend(polyline.divisions)
1514        return res
1515
1516    def iter_main_intersections(self) -> Iterator:
1517        """Iterate over the main intersections.
1518
1519        Yields:
1520            Intersection: Intersection object.
1521        """
1522        for ppoly in self.parallel_poly_list:
1523            for division in ppoly.polyline.divisions:
1524                yield from division.intersections
1525
1526    def iter_offset_intersections(self) -> Iterator:
1527        """
1528        Iterate over the offset intersections.
1529
1530        Yields:
1531            Intersection: Intersection object.
1532        """
1533        for ppoly in self.parallel_poly_list:
1534            for poly in ppoly.offset_poly_list:
1535                for division in poly.divisions:
1536                    yield from division.intersections
1537
1538    def iter_offset_sections(self) -> Iterator:
1539        """
1540        Iterate over the offset sections.
1541
1542        Yields:
1543            Section: Section object.
1544        """
1545        for ppoly in self.parallel_poly_list:
1546            for poly in ppoly.offset_poly_list:
1547                for division in poly.divisions:
1548                    yield from division.sections
1549
1550    def iter_main_sections(self) -> Iterator:
1551        """Iterate over the main sections.
1552
1553        Yields:
1554            Section: Section object.
1555        """
1556        for ppoly in self.parallel_poly_list:
1557            for division in ppoly.polyline.divisions:
1558                yield from division.sections
1559
1560    def iter_offset_divisions(self) -> Iterator:
1561        """
1562        Iterate over the offset divisions.
1563
1564        Yields:
1565            Division: Division object.
1566        """
1567        for ppoly in self.parallel_poly_list:
1568            for poly in ppoly.offset_poly_list:
1569                yield from poly.divisions
1570
1571    def iter_main_divisions(self) -> Iterator:
1572        """
1573        Iterate over the main divisions.
1574
1575        Yields:
1576            Division: Division object.
1577        """
1578        for ppoly in self.parallel_poly_list:
1579            yield from ppoly.polyline.divisions
1580
1581    @property
1582    def main_divisions(self) -> List[Division]:
1583        """Main divisions are the divisions of the main polyline.
1584
1585        Returns:
1586            list: List of main divisions.
1587        """
1588        res = []
1589        for parallel_polyline in self.parallel_poly_list:
1590            res.extend(parallel_polyline.polyline.divisions)
1591        return res
1592
1593    @property
1594    def offset_divisions(self) -> List[Division]:
1595        """Offset divisions are the divisions of the offset polylines.
1596
1597        Returns:
1598            list: List of offset divisions.
1599        """
1600        res = []
1601        for parallel_polyline in self.parallel_poly_list:
1602            for polyline in parallel_polyline.offset_poly_list:
1603                res.extend(polyline.divisions)
1604        return res
1605
1606    @property
1607    def intersections(self) -> List[Intersection]:
1608        """Return all the intersections in the parallel_poly_list.
1609
1610        Returns:
1611            list: List of intersections.
1612        """
1613        res = []
1614        for parallel_polyline in self.parallel_poly_list:
1615            for polyline in parallel_polyline.polyline_list:
1616                res.extend(polyline.intersections)
1617        return res
1618
1619    # @timing
1620    def _set_polyline_list(self):
1621        """
1622        Populate the self.polyline_list list with Polyline objects.
1623
1624        * Internal use only.
1625
1626        Arguments:
1627        ----------
1628            None
1629
1630        Return:
1631        --------
1632            None
1633
1634        Prerequisites:
1635        --------------
1636
1637            * self.polygon_shapes and/or self.polyline_shapes must be
1638              established.
1639        """
1640        self.polyline_list = []
1641        if self.polygon_shapes:
1642            for polygon in self.polygon_shapes:
1643                self.polyline_list.append(Polyline(polygon.vertices, closed=True))
1644        if self.polyline_shapes:
1645            for polyline in self.polyline_shapes:
1646                self.polyline_list.append(Polyline(polyline.vertices, closed=False))
1647
1648    # @timing
1649    def _set_parallel_poly_list(self):
1650        """
1651        Populate the self.parallel_poly_list list with ParallelPolyline
1652        objects.
1653
1654        Arguments:
1655        ----------
1656            None
1657
1658        Return:
1659        --------
1660            None
1661
1662        Prerequisites:
1663        --------------
1664            * self.polygon_shapes and/or self.polyline_shapes must be
1665              established prior to this.
1666            * Parallel polylines are created by offsetting the original
1667              polygon and polyline shapes in two directions using the
1668              self.offset value.
1669
1670        Notes:
1671        ------
1672            This method is called by the Lace constructor.  It is not
1673            for users to call directly. Without this method, the Lace
1674            object cannot be created.
1675        """
1676        self.parallel_poly_list = []
1677        if self.polyline_list:
1678            for _, polyline in enumerate(self.polyline_list):
1679                self.parallel_poly_list.append(
1680                    ParallelPolyline(
1681                        polyline,
1682                        self.offset,
1683                        lace=self,
1684                        closed=polyline.closed,
1685                        dist_tol=defaults["dist_tol"]
1686                    )
1687                )
1688
1689    # @timing
1690    def _set_overlaps(self):
1691        """
1692        Populate the self.overlaps list with Overlap objects. Side
1693        effects listed below.
1694
1695        Arguments:
1696        ----------
1697            None
1698
1699        Return:
1700        --------
1701            None
1702
1703        Side Effects:
1704        -------------
1705            * self.overlaps is populated with Overlap objects.
1706            * Section objects' overlap attribute is populated with the
1707              corresponding Overlap object that they are a part of. Not
1708              all sections will have an overlap.
1709
1710        Prerequisites:
1711        --------------
1712            self.polyline and self.parallel_poly_list must be populated.
1713            self.main_intersections, self.offset_sections and
1714            self.d_intersections must be populated prior to creating
1715            the overlaps.
1716
1717        Notes:
1718        ------
1719            This method is called by the Lace constructor.  It is not
1720            for users to call directly.
1721            Without this method, the Lace object cannot be created.
1722        """
1723        G = nx.Graph()
1724        for section in self.iter_offset_sections():
1725            if section.is_overlap:
1726                G.add_edge(section.start.id, section.end.id, section=section)
1727        cycles = nx.cycle_basis(G)
1728        for cycle in cycles:
1729            cycle.append(cycle[0])
1730            edges = connected_pairs(cycle)
1731            sections = [G.edges[edge]["section"] for edge in edges]
1732            s_intersections = set()
1733            for section in sections:
1734                s_intersections.add(section.start.id)
1735                s_intersections.add(section.end.id)
1736            intersections = [self.d_intersections[i] for i in s_intersections]
1737            overlap = Overlap(intersections=intersections, sections=sections)
1738            for section in sections:
1739                section.overlap = overlap
1740            for edge in edges:
1741                section = G.edges[edge]["section"]
1742                section.start.overlap = overlap
1743                section.end.overlap = overlap
1744            self.overlaps.append(overlap)
1745        for overlap in self.overlaps:
1746            for section in overlap.sections:
1747                if section.is_over:
1748                    line_width = 3
1749                else:
1750                    line_width = 1
1751                line = Shape(
1752                    [section.start.point, section.end.point], line_width=line_width
1753                )
1754        for division in self.offset_divisions:
1755            p1 = division.start.point
1756            p2 = division.end.point
1757
1758    def set_plaits(self):
1759        """
1760        Populate the self.plaits list with Plait objects. Plaits are
1761        optional for drawing. They form the under/over interlacing. They
1762        are created if the "with_plaits" argument is set to be True in
1763        the constructor. with_plaits is True by default but this can be
1764        changed by setting the auto_plaits value to False in the
1765        settings.py This method can be called by the user to create the
1766        plaits after the creation of the Lace object if they were not
1767        created initally.
1768
1769        * Can be called by users.
1770
1771        Arguments:
1772        ----------
1773            None
1774
1775        Return:
1776        --------
1777            None
1778
1779        Side Effects:
1780        -------------
1781            * self.plaits is populated with Plait objects.
1782
1783        Prerequisites:
1784        --------------
1785            self.polyline and self.parallel_poly_list must be populated.
1786            self.divisions and self.intersections must be populated.
1787            self.overlaps must be populated.
1788
1789        Where used:
1790        -----------
1791            Lace.__init__
1792
1793        Notes:
1794        ------
1795            This method is called by the Lace constructor.  It is not
1796            for users to call directly. Without this method, the Lace
1797            object cannot be created.
1798        """
1799        if self.plaits:
1800            return
1801        plait_sections = []
1802        for division in self.iter_offset_divisions():
1803            merged_sections = division._merged_sections()
1804            for merged in merged_sections:
1805                plait_sections.append((merged[0], merged[-1]))
1806
1807        # connect the open ends of the polyline_shapes
1808        for ppoly in self.parallel_poly_list:
1809            if not ppoly.closed:
1810                polyline1 = ppoly.offset_poly_list[0]
1811                polyline2 = ppoly.offset_poly_list[1]
1812                p1_start_x = polyline1.intersections[0]
1813                p1_end_x = polyline1.intersections[-1]
1814                p2_start_x = polyline2.intersections[0]
1815                p2_end_x = polyline2.intersections[-1]
1816
1817                plait_sections.append((p1_start_x, p2_start_x))
1818                plait_sections.append((p1_end_x, p2_end_x))
1819        for sec in self.iter_offset_sections():
1820            if not sec.is_over and sec.is_overlap:
1821                plait_sections.append((sec.start, sec.end))
1822
1823        graph_edges = [(r[0].id, r[1].id) for r in plait_sections]
1824        cycles = get_cycles(graph_edges)
1825        plaits = []
1826        count = 0
1827        for cycle in cycles:
1828            cycle = connected_pairs(cycle)
1829            dup = cycle[1:]
1830            plait = [cycle[0][0], cycle[0][1]]
1831            for _ in range(len(cycle) - 1):
1832                last = plait[-1]
1833                for edge in dup:
1834                    if edge[0] == last:
1835                        plait.append(edge[1])
1836                        dup.remove(edge)
1837                        break
1838                    if edge[1] == last:
1839                        plait.append(edge[0])
1840                        dup.remove(edge)
1841                        break
1842            plaits.append(plait)
1843            count += 1
1844        d_x = self.d_intersections
1845        for plait in plaits:
1846            intersections = [d_x[x] for x in plait]
1847            vertices = [x.point for x in intersections]
1848            if not right_handed(vertices):
1849                vertices.reverse()
1850                plait.reverse()
1851            shape = Shape(vertices)
1852            shape.intersections = intersections
1853            shape.fill_color = colors.gold
1854            shape.inner_lines = None
1855            shape.subtype = Types.PLAIT
1856            self.plaits.append(shape)
1857
1858    # @timing
1859    def _set_intersections(self):
1860        """
1861        Compute all intersection points (by calling all_intersections)
1862        among the divisions of the polylines (both main and offset).
1863        Populate the self.main_intersections and
1864        self.offset_intersections lists with Intersection objects. This
1865        method is called by the Lace constructor and customized to be
1866        used with Lace objects only. Without this method, the Lace
1867        object cannot be created.
1868
1869        * Internal use only!
1870
1871        Arguments:
1872        ----------
1873            None
1874
1875        Return:
1876        --------
1877            None
1878
1879        Side Effects:
1880        -------------
1881            * self.main_intersections are populated.
1882            * self.offset_intersections are populated.
1883            * "sections" attribute of the divisions are populated.
1884            * "is_overlap" attribute of the sections are populated.
1885            * "intersections" attribute of the divisions are populated.
1886            * "endpoint" attribute of the intersections are populated.
1887
1888        Where used:
1889        -----------
1890            Lace.__init__
1891
1892        Prerequisites:
1893        --------------
1894            * self.main_divisions must be populated.
1895            * self.offset_divisions must be populated.
1896            * Two endpoint intersections of the divisions must be set.
1897
1898        Notes:
1899        ------
1900            This method is called by the Lace constructor.  It is not
1901            for users to call directly. Without this method, the Lace
1902            object cannot be created.
1903            Works only for regular under/over interlacing.
1904        """
1905        # set intersections for the main polylines
1906        main_divisions = self.main_divisions
1907        offset_divisions = self.offset_divisions
1908        self.main_intersections = all_intersections(
1909            main_divisions, self.d_intersections, self.d_connections
1910        )
1911
1912        # set sections for the main divisions
1913        self.main_sections = []
1914        for division in main_divisions:
1915            division.intersections[0].endpoint = True
1916            division.intersections[-1].endpoint = True
1917            segments = connected_pairs(division.intersections)
1918            division.sections = []
1919            for i, segment in enumerate(segments):
1920                section = Section(*segment)
1921                if i % 2 == 1:
1922                    section.is_overlap = True
1923                division.sections.append(section)
1924                self.main_sections.append(section)
1925        # set intersections for the offset polylines
1926        self.offset_intersections = all_intersections(
1927            offset_divisions, self.d_intersections, self.d_connections
1928        )
1929        # set sections for the offset divisions
1930        self.offset_sections = []
1931        for i, division in enumerate(offset_divisions):
1932            division.intersections[0].endpoint = True
1933            division.intersections[-1].endpoint = True
1934            lines = connected_pairs(division.intersections)
1935            division.sections = []
1936            for j, line in enumerate(lines):
1937                section = Section(*line)
1938                if j % 2 == 1:
1939                    section.is_overlap = True
1940                division.sections.append(section)
1941                self.offset_sections.append(section)
1942
1943    def _all_polygons(self, polylines, rtol=None):
1944        """Return a list of polygons from a list of lists of points.
1945        polylines: [[(x1, y1), (x2, y2)], [(x3, y3), (x4, y4)], ...]
1946        return [[(x1, y1), (x2, y2), (x3, y3), ...], ...]
1947
1948        Args:
1949            polylines (list): List of lists of points.
1950            rtol (float, optional): Relative tolerance. Defaults to None.
1951
1952        Returns:
1953            list: List of polygons.
1954        """
1955        if rtol is None:
1956            rtol = self.rtol
1957        return get_polygons(polylines, rtol)
1958
1959    # @timing
1960    def _set_over_under(self):
1961        def next_poly(exclude):
1962            for ppoly in self.parallel_poly_list:
1963                poly1, poly2 = ppoly.offset_poly_list
1964                if poly1 in exclude:
1965                    continue
1966                ind = 0
1967                for sec in poly1.iter_sections():
1968                    if sec.is_overlap:
1969                        if sec.overlap.drawable and sec.overlap.visited:
1970                            even_odd = ind % 2 == 1
1971                            return (poly1, poly2, even_odd)
1972                        ind += 1
1973            return (None, None, None)
1974
1975        for ppoly in self.parallel_poly_list:
1976            poly1, poly2 = ppoly.offset_poly_list
1977            exclude = []
1978            even_odd = 0
1979            while poly1:
1980                ind = 0
1981                for i, division in enumerate(poly1.divisions):
1982                    for j, section in enumerate(division.sections):
1983                        if section.is_overlap:
1984                            if section.overlap is None:
1985                                msg = (
1986                                    "Overlap section in the lace has no "
1987                                    "overlap object.\n"
1988                                    "Try different offset value and/or "
1989                                    "tolerance."
1990                                )
1991                                raise RuntimeError(msg)
1992                            section.overlap.visited = True
1993                            if ind % 2 == even_odd:
1994                                if section.overlap.drawable:
1995                                    section.overlap.drawable = False
1996                                    section.is_over = True
1997                                    section2 = poly2.divisions[i].sections[j]
1998                                    section2.is_over = True
1999                            ind += 1
2000                exclude.extend([poly1, poly2])
2001                poly1, poly2, even_odd = next_poly(exclude)
2002
2003    def fragment_edge_graph(self) -> nx.Graph:
2004        """
2005        Return a networkx graph of the connected fragments.
2006        If two fragments have a "common" division then they are connected.
2007
2008        Returns:
2009            nx.Graph: Graph of connected fragments.
2010        """
2011        G = nx.Graph()
2012        fragments = [(f.area, f) for f in self.fragments]
2013        fragments.sort(key=lambda x: x[0], reverse=True)
2014        for fragment in [f[1] for f in fragments[1:]]:
2015            for division in fragment.divisions:
2016                if division.twin and division.twin.fragment:
2017                    fragment2 = division.twin.fragment
2018                    G.add_node(fragment.id, fragment=fragment)
2019                    G.add_node(fragment2.id, fragment=fragment2)
2020                    G.add_edge(fragment.id, fragment2.id, edge=division)
2021        return G
2022
2023    def fragment_vertex_graph(self) -> nx.Graph:
2024        """
2025        Return a networkx graph of the connected fragments.
2026        If two fragments have a "common" vertex then they are connected.
2027
2028        Returns:
2029            nx.Graph: Graph of connected fragments.
2030        """
2031        def get_neighbours(intersection):
2032            division = intersection.division
2033            if not division.next.twin:
2034                return None
2035            fragments = [division.fragment]
2036            start_division_id = division.id
2037            if division.next.twin:
2038                twin_id = division.next.twin.id
2039            else:
2040                return None
2041            while twin_id != start_division_id:
2042                if division.next.twin:
2043                    if division.fragment.id not in [x.id for x in fragments]:
2044                        fragments.append(division.fragment)
2045                    twin_id = division.next.twin.id
2046                    division = division.next.twin
2047                else:
2048                    if division.next.fragment.id not in [x.id for x in fragments]:
2049                        fragments.append(division.next.fragment)
2050                    break
2051
2052            return fragments
2053
2054        neighbours = []
2055        for fragment in self.fragments:
2056            for intersection in fragment.intersections:
2057                neighbours.append(get_neighbours(intersection))
2058        G = self.fragment_edge_graph()
2059        G2 = nx.Graph()
2060        for n in neighbours:
2061            if n:
2062                if len(n) > 2:
2063                    for pair in combinations(n, 2):
2064                        ids = tuple([x.id for x in pair])
2065                        if ids not in G.edges:
2066                            G2.add_node(ids[0], fragment=pair[0])
2067                            G2.add_node(ids[1], fragment=pair[1])
2068                            G2.add_edge(*ids)
2069        return G2

A Lace is a collection of ParallelPolylines objects. They are used to create interlace patterns.

Arguments:
  • polygon_shapes (Union[Batch, list[Shape]], optional): List of polygon shapes. Defaults to None.
  • polyline_shapes (Union[Batch, list[Shape]], optional): List of polyline shapes. Defaults to None.
  • offset (float, optional): Offset value. Defaults to 2.
  • rtol (float, optional): Relative tolerance. Defaults to None.
  • swatch (list, optional): Swatch list. Defaults to None.
  • breakpoints (list, optional): Breakpoints list. Defaults to None.
  • plait_color (colors.Color, optional): Plait color. Defaults to None.
  • draw_fragments (bool, optional): If fragments should be drawn. Defaults to True.
  • palette (list, optional): Palette list. Defaults to None.
  • color_step (int, optional): Color step. Defaults to 1.
  • with_plaits (bool, optional): If plaits should be included. Defaults to True.
  • area_threshold (float, optional): Area threshold. Defaults to None.
  • radius_threshold (float, optional): Radius threshold. Defaults to None.
  • **kwargs: Additional attributes for cosmetic/drawing purposes.
Lace( polygon_shapes: Union[simetri.graphics.batch.Batch, list[simetri.graphics.shape.Shape]] = None, polyline_shapes: Union[simetri.graphics.batch.Batch, list[simetri.graphics.shape.Shape]] = None, offset: float = 2, rtol: float = None, swatch: list = None, breakpoints: list = None, plait_color: simetri.colors.colors.Color = None, draw_fragments: bool = True, palette: list = None, color_step: int = 1, with_plaits: bool = True, area_threshold: float = None, radius_threshold: float = None, **kwargs)
 966    def __init__(
 967        self,
 968        polygon_shapes: Union[Batch, list[Shape]] = None,
 969        polyline_shapes: Union[Batch, list[Shape]] = None,
 970        offset: float = 2,
 971        rtol: float = None,
 972        swatch: list = None,
 973        breakpoints: list = None,
 974        plait_color: colors.Color = None,
 975        draw_fragments: bool = True,
 976        palette: list = None,
 977        color_step: int = 1,
 978        with_plaits: bool = True,
 979        area_threshold: float = None,
 980        radius_threshold: float = None,
 981        **kwargs,
 982    ) -> None:
 983        validate_args(kwargs, shape_style_map)
 984        (
 985            rtol,
 986            swatch,
 987            plait_color,
 988            draw_fragments,
 989            area_threshold,
 990            radius_threshold,
 991        ) = get_defaults(
 992            [
 993                "rtol",
 994                "swatch",
 995                "plait_color",
 996                "draw_fragments",
 997                "area_threshold",
 998                "radius_threshold",
 999            ],
1000            [
1001                rtol,
1002                swatch,
1003                plait_color,
1004                draw_fragments,
1005                area_threshold,
1006                radius_threshold,
1007            ],
1008        )
1009        if polygon_shapes:
1010            polygon_shapes = polygon_shapes.merge_shapes()
1011            self.polygon_shapes = self._check_polygons(polygon_shapes)
1012        else:
1013            self.polygon_shapes = []
1014        if polyline_shapes:
1015            polyline_shapes = polyline_shapes.merge_shapes()
1016            self.polyline_shapes = self._check_polylines(polyline_shapes)
1017        else:
1018            self.polyline_shapes = []
1019        if not self.polygon_shapes and not self.polyline_shapes:
1020            msg = "Lace.__init__ : No polygons or polylines found."
1021            raise ValueError(msg)
1022        self.polyline_shapes = polyline_shapes
1023        self.offset = offset
1024        self.main_intersections = None
1025        self.offset_intersections = None
1026        self.xform_matrix = np.eye(3)
1027        self.rtol = rtol
1028        self.swatch = swatch
1029        self.breakpoints = breakpoints
1030        self.plait_color = plait_color
1031        self.draw_fragments = draw_fragments
1032        self.palette = palette
1033        self.color_step = color_step
1034        self.with_plaits = with_plaits
1035        self.d_intersections = {}  # key, value:intersection.id, intersection
1036        self.d_connections = {}
1037        self.plaits = []
1038        self.overlaps = []
1039        self._groups = None
1040        self.area_threshold = area_threshold
1041        self.radius_threshold = radius_threshold
1042        if kwargs and "_copy" in kwargs:
1043            # pass the pre-computed values
1044            for k, v in kwargs:
1045                if k == "_copy":
1046                    continue
1047                else:
1048                    setattr(self, k, v)
1049        else:
1050            self._set_polyline_list()  # main divisions are set here along with polylines
1051            # polyline.divisions is the list of Division objects
1052            self._set_parallel_poly_list()
1053            # start_time2 = time.perf_counter()
1054            self._set_intersections()
1055            # end_time2 = time.perf_counter()
1056            # print(
1057            #     f"Lace.__init__ intersections computed in {end_time2 - start_time2:0.4f} seconds"
1058            # )
1059
1060            self._set_overlaps()
1061            self._set_twin_sections()
1062            self._set_fragments()
1063            if not self.polyline_shapes:
1064                self._set_outline()
1065            # self._set_partitions()
1066            self._set_over_under()
1067            if self.with_plaits:
1068                self.set_plaits()
1069            # self._set_convex_hull()
1070            # self._set_concave_hull()
1071            # self._set_fragment_groups()
1072            # self._set_partition_groups()
1073            self._b_box = None
1074        elements = [polyline for polyline in self.parallel_poly_list] + self.fragments
1075
1076        if "debug" in kwargs:
1077            kwargs.pop("debug")
1078        super().__init__(elements, **kwargs)
1079        if kwargs and "_copy" not in kwargs:
1080            for k, v in kwargs.items():
1081                if k in shape_style_map:
1082                    setattr(self, k, v)  # todo: we should check for valid values here
1083                else:
1084                    raise AttributeError(f"{k}. Invalid attribute!")
1085        self.subtype = Types.LACE
1086        common_properties(self)

Initialize a Batch object.

Arguments:
  • elements (Sequence[Any], optional): The elements to include in the batch.
  • modifiers (Sequence[Modifier], optional): The modifiers to apply to the batch.
  • subtype (Types, optional): The subtype of the batch.
  • kwargs (dict): Additional keyword arguments.
polyline_shapes
offset
main_intersections
offset_intersections
xform_matrix
rtol
swatch
breakpoints
plait_color
draw_fragments
palette
color_step
with_plaits
d_intersections
d_connections
plaits
overlaps
area_threshold
radius_threshold
subtype
center
1088    @property
1089    def center(self):
1090        """Return the center of the lace.
1091
1092        Returns:
1093            list: Center coordinates.
1094        """
1095        return self.outline.CG

Return the center of the lace.

Returns:

list: Center coordinates.

fragment_groups
1097    @property
1098    def fragment_groups(self):
1099        """Return the fragment groups of the lace.
1100
1101        Returns:
1102            dict: Dictionary of fragment groups.
1103        """
1104        center = self.center
1105        radius_frag = []
1106        for fragment in self.fragments:
1107            radius = int(distance(center, fragment.CG))
1108            for rad, frag in radius_frag:
1109                if abs(radius - rad) <= 2:
1110                    radius = rad
1111                    break
1112            radius_frag.append((radius, fragment))
1113
1114        radius_frag.sort(key=lambda x: x[0])
1115
1116        d_groups = {}
1117        for i, (radius, fragment) in enumerate(radius_frag):
1118            if i == 0:
1119                d_groups[radius] = [fragment]
1120            else:
1121                if radius in d_groups:
1122                    d_groups[radius].append(fragment)
1123                else:
1124                    d_groups[radius] = [fragment]
1125
1126        return d_groups

Return the fragment groups of the lace.

Returns:

dict: Dictionary of fragment groups.

def set_fragment_groups(self):
1235    def set_fragment_groups(self):
1236        # to do : handle repeated code. same in _set_partition_groups
1237        areas = []
1238        for i, fragment in enumerate(self.fragments):
1239            areas.append((fragment.area, i))
1240        areas.sort()
1241        bins = group_into_bins(areas, self.area_threshold)
1242        self.fragments_by_area = OrderedDict()
1243        for i, bin in enumerate(bins):
1244            area_values = [x[0] for x in bin]
1245            key = sum([x[0] for x in bin]) / len(bin)
1246            fragments = []
1247            for area, ind in areas:
1248                if area in area_values:
1249                    fragments.append(self.fragments[ind])
1250            self.fragments_by_area[key] = fragments
1251
1252        radii = []
1253        for i, fragment in enumerate(self.fragments):
1254            radii.append((distance(self.center, fragment.CG), i))
1255        radii.sort()
1256        bins = group_into_bins(radii, self.radius_threshold)
1257        self.fragments_by_radius = OrderedDict()
1258        for i, bin in enumerate(bins):
1259            radius_values = [x[0] for x in bin]
1260            key = sum([x[0] for x in bin]) / len(bin)
1261            fragments = []
1262            for radius, ind in radii:
1263                if radius in radius_values:
1264                    fragments.append(self.fragments[ind])
1265            self.fragments_by_radius[key] = fragments
def copy(self):
1347    def copy(self):
1348        class Dummy(Lace):
1349            pass
1350
1351        # we need to copy the polyline_list and parallel_poly_list
1352        for polyline in self.polyline_list:
1353            polyline.copy()

Returns a copy of the batch.

Returns:

Batch: A copy of the batch.

def get_sketch(self):
1355    def get_sketch(self):
1356        """
1357        Create and return a Sketch object. Sketch is a Batch object
1358        with Shape elements corresponding to the vertices of the plaits
1359        and fragments of the Lace instance. They have 'plaits' and
1360        'fragments' attributes to hold lists of Shape objects populated
1361        with plait and fragment vertices of the Lace instance
1362        respectively. They are used for drawing multiple copies of the
1363        original lace pattern. They are light-weight compared to the
1364        Lace objects since they only contain sufficient data to draw the
1365        lace objects. Hundreds of these objects can be used to create
1366        wallpaper patterns or other patterns without having to contain
1367        unnecessary data. They do not share points with the original
1368        Lace object.
1369
1370        Arguments:
1371        ----------
1372            None
1373
1374        Prerequisites:
1375        --------------
1376            * A lace object to be copied.
1377
1378        Side effects:
1379        -------------
1380            None
1381
1382        Return:
1383        --------
1384            A Sketch object.
1385        """
1386        fragments = []
1387        for fragment in self.fragments:
1388            polygon = Shape(fragment.vertices)
1389            polygon.fill = True
1390            polygon.subtype = Types.FRAGMENT
1391            fragments.append(polygon)
1392
1393        plaits = []
1394        for plait in self.plaits:
1395            polygon = Shape(plait.vertices)
1396            polygon.fill = True
1397            polygon.subtype = Types.PLAIT
1398            plaits.append(polygon)
1399
1400        sketch = Batch((fragments + plaits))
1401        sketch.fragments = fragments
1402        sketch.plaits = plaits
1403        sketch.outline = self.outline
1404        sketch.subtype = Types.SKETCH
1405
1406        sketch.draw_plaits = True
1407        sketch.draw_fragments = True
1408        return sketch

Create and return a Sketch object. Sketch is a Batch object with Shape elements corresponding to the vertices of the plaits and fragments of the Lace instance. They have 'plaits' and 'fragments' attributes to hold lists of Shape objects populated with plait and fragment vertices of the Lace instance respectively. They are used for drawing multiple copies of the original lace pattern. They are light-weight compared to the Lace objects since they only contain sufficient data to draw the lace objects. Hundreds of these objects can be used to create wallpaper patterns or other patterns without having to contain unnecessary data. They do not share points with the original Lace object.

Arguments:

None

Prerequisites:

* A lace object to be copied.

Side effects:

None

Return:

A Sketch object.
def group_fragments(self, tol=None):
1410    def group_fragments(self, tol=None):
1411        """Group the fragments by the number of vertices and the area.
1412
1413        Args:
1414            tol (float, optional): Tolerance value. Defaults to None.
1415
1416        Returns:
1417            list: List of grouped fragments.
1418        """
1419        if tol is None:
1420            tol = defaults["tol"]
1421        frags = self.fragments
1422        vert_groups = [
1423            [frag for frag in frags if len(frag.vertices) == n]
1424            for n in set([len(f.vertices) for f in frags])
1425        ]
1426        groups = []
1427        for group in vert_groups:
1428            areas = [
1429                [f for f in group if isclose(f.area, area, rtol=tol)]
1430                for area in set([frag.area for frag in group])
1431            ]
1432            areas.sort(key=lambda x: x[0].area, reverse=True)
1433            groups.append(areas)
1434        groups.sort(key=lambda x: x[0][0].area, reverse=True)
1435
1436        return groups

Group the fragments by the number of vertices and the area.

Arguments:
  • tol (float, optional): Tolerance value. Defaults to None.
Returns:

list: List of grouped fragments.

def get_fragment_cycles(self):
1438    def get_fragment_cycles(self):
1439        """
1440        Iterate over the offset sections and create a graph of the
1441        intersections (start and end of the sections). Then find the
1442        cycles in the graph. self.d_intersections is used to map
1443        the graph nodes to the actual intersection points.
1444
1445        Returns:
1446            list: List of fragment cycles.
1447        """
1448        graph_edges = []
1449        for section in self.iter_offset_sections():
1450            if section.is_overlap:
1451                continue
1452            graph_edges.append((section.start.id, section.end.id))
1453
1454        return get_cycles(graph_edges)

Iterate over the offset sections and create a graph of the intersections (start and end of the sections). Then find the cycles in the graph. self.d_intersections is used to map the graph nodes to the actual intersection points.

Returns:

list: List of fragment cycles.

def set_plait_lines(self, n, offset, line_color=Color(0.012, 0.263, 0.875), line_width=1):
1469    def set_plait_lines(self, n, offset, line_color=colors.blue, line_width=1):
1470        """Create offset lines inside the plaits of the lace.
1471
1472        Args:
1473            n (int): Number of lines.
1474            offset (float): Offset value.
1475            line_color (colors.Color, optional): Line color. Defaults to colors.blue.
1476            line_width (int, optional): Line width. Defaults to 1.
1477        """
1478        for plait in self.plaits:
1479            plait.inner_lines = []
1480            self._set_inner_lines(plait, n, offset, line_color, line_width)

Create offset lines inside the plaits of the lace.

Arguments:
  • n (int): Number of lines.
  • offset (float): Offset value.
  • line_color (colors.Color, optional): Line color. Defaults to colors.blue.
  • line_width (int, optional): Line width. Defaults to 1.
def set_fragment_lines( self, n: int, offset: float, line_color: simetri.colors.colors.Color = Color(0.012, 0.263, 0.875), line_width=1) -> None:
1482    def set_fragment_lines(
1483        self,
1484        n: int,
1485        offset: float,
1486        line_color: colors.Color = colors.blue,
1487        line_width=1,
1488    ) -> None:
1489        """
1490        Create offset lines inside the fragments of the lace.
1491
1492        Args:
1493            n (int): Number of lines.
1494            offset (float): Offset value.
1495            line_color (colors.Color, optional): Line color. Defaults to colors.blue.
1496            line_width (int, optional): Line width. Defaults to 1.
1497        """
1498        for fragment in self.fragments:
1499            fragment.inner_lines = []
1500            self._set_inner_lines(fragment, n, offset, line_color, line_width)

Create offset lines inside the fragments of the lace.

Arguments:
  • n (int): Number of lines.
  • offset (float): Offset value.
  • line_color (colors.Color, optional): Line color. Defaults to colors.blue.
  • line_width (int, optional): Line width. Defaults to 1.
all_divisions: List
1502    @property
1503    def all_divisions(self) -> List:
1504        """
1505        Return a list of all the divisions (both main and offset) in the lace.
1506
1507        Returns:
1508            list: List of all divisions.
1509        """
1510        res = []
1511        for parallel_polyline in self.parallel_poly_list:
1512            for polyline in parallel_polyline.polyline_list:
1513                res.extend(polyline.divisions)
1514        return res

Return a list of all the divisions (both main and offset) in the lace.

Returns:

list: List of all divisions.

def iter_main_intersections(self) -> Iterator:
1516    def iter_main_intersections(self) -> Iterator:
1517        """Iterate over the main intersections.
1518
1519        Yields:
1520            Intersection: Intersection object.
1521        """
1522        for ppoly in self.parallel_poly_list:
1523            for division in ppoly.polyline.divisions:
1524                yield from division.intersections

Iterate over the main intersections.

Yields:

Intersection: Intersection object.

def iter_offset_intersections(self) -> Iterator:
1526    def iter_offset_intersections(self) -> Iterator:
1527        """
1528        Iterate over the offset intersections.
1529
1530        Yields:
1531            Intersection: Intersection object.
1532        """
1533        for ppoly in self.parallel_poly_list:
1534            for poly in ppoly.offset_poly_list:
1535                for division in poly.divisions:
1536                    yield from division.intersections

Iterate over the offset intersections.

Yields:

Intersection: Intersection object.

def iter_offset_sections(self) -> Iterator:
1538    def iter_offset_sections(self) -> Iterator:
1539        """
1540        Iterate over the offset sections.
1541
1542        Yields:
1543            Section: Section object.
1544        """
1545        for ppoly in self.parallel_poly_list:
1546            for poly in ppoly.offset_poly_list:
1547                for division in poly.divisions:
1548                    yield from division.sections

Iterate over the offset sections.

Yields:

Section: Section object.

def iter_main_sections(self) -> Iterator:
1550    def iter_main_sections(self) -> Iterator:
1551        """Iterate over the main sections.
1552
1553        Yields:
1554            Section: Section object.
1555        """
1556        for ppoly in self.parallel_poly_list:
1557            for division in ppoly.polyline.divisions:
1558                yield from division.sections

Iterate over the main sections.

Yields:

Section: Section object.

def iter_offset_divisions(self) -> Iterator:
1560    def iter_offset_divisions(self) -> Iterator:
1561        """
1562        Iterate over the offset divisions.
1563
1564        Yields:
1565            Division: Division object.
1566        """
1567        for ppoly in self.parallel_poly_list:
1568            for poly in ppoly.offset_poly_list:
1569                yield from poly.divisions

Iterate over the offset divisions.

Yields:

Division: Division object.

def iter_main_divisions(self) -> Iterator:
1571    def iter_main_divisions(self) -> Iterator:
1572        """
1573        Iterate over the main divisions.
1574
1575        Yields:
1576            Division: Division object.
1577        """
1578        for ppoly in self.parallel_poly_list:
1579            yield from ppoly.polyline.divisions

Iterate over the main divisions.

Yields:

Division: Division object.

main_divisions: List[Division]
1581    @property
1582    def main_divisions(self) -> List[Division]:
1583        """Main divisions are the divisions of the main polyline.
1584
1585        Returns:
1586            list: List of main divisions.
1587        """
1588        res = []
1589        for parallel_polyline in self.parallel_poly_list:
1590            res.extend(parallel_polyline.polyline.divisions)
1591        return res

Main divisions are the divisions of the main polyline.

Returns:

list: List of main divisions.

offset_divisions: List[Division]
1593    @property
1594    def offset_divisions(self) -> List[Division]:
1595        """Offset divisions are the divisions of the offset polylines.
1596
1597        Returns:
1598            list: List of offset divisions.
1599        """
1600        res = []
1601        for parallel_polyline in self.parallel_poly_list:
1602            for polyline in parallel_polyline.offset_poly_list:
1603                res.extend(polyline.divisions)
1604        return res

Offset divisions are the divisions of the offset polylines.

Returns:

list: List of offset divisions.

intersections: List[Intersection]
1606    @property
1607    def intersections(self) -> List[Intersection]:
1608        """Return all the intersections in the parallel_poly_list.
1609
1610        Returns:
1611            list: List of intersections.
1612        """
1613        res = []
1614        for parallel_polyline in self.parallel_poly_list:
1615            for polyline in parallel_polyline.polyline_list:
1616                res.extend(polyline.intersections)
1617        return res

Return all the intersections in the parallel_poly_list.

Returns:

list: List of intersections.

def set_plaits(self):
1758    def set_plaits(self):
1759        """
1760        Populate the self.plaits list with Plait objects. Plaits are
1761        optional for drawing. They form the under/over interlacing. They
1762        are created if the "with_plaits" argument is set to be True in
1763        the constructor. with_plaits is True by default but this can be
1764        changed by setting the auto_plaits value to False in the
1765        settings.py This method can be called by the user to create the
1766        plaits after the creation of the Lace object if they were not
1767        created initally.
1768
1769        * Can be called by users.
1770
1771        Arguments:
1772        ----------
1773            None
1774
1775        Return:
1776        --------
1777            None
1778
1779        Side Effects:
1780        -------------
1781            * self.plaits is populated with Plait objects.
1782
1783        Prerequisites:
1784        --------------
1785            self.polyline and self.parallel_poly_list must be populated.
1786            self.divisions and self.intersections must be populated.
1787            self.overlaps must be populated.
1788
1789        Where used:
1790        -----------
1791            Lace.__init__
1792
1793        Notes:
1794        ------
1795            This method is called by the Lace constructor.  It is not
1796            for users to call directly. Without this method, the Lace
1797            object cannot be created.
1798        """
1799        if self.plaits:
1800            return
1801        plait_sections = []
1802        for division in self.iter_offset_divisions():
1803            merged_sections = division._merged_sections()
1804            for merged in merged_sections:
1805                plait_sections.append((merged[0], merged[-1]))
1806
1807        # connect the open ends of the polyline_shapes
1808        for ppoly in self.parallel_poly_list:
1809            if not ppoly.closed:
1810                polyline1 = ppoly.offset_poly_list[0]
1811                polyline2 = ppoly.offset_poly_list[1]
1812                p1_start_x = polyline1.intersections[0]
1813                p1_end_x = polyline1.intersections[-1]
1814                p2_start_x = polyline2.intersections[0]
1815                p2_end_x = polyline2.intersections[-1]
1816
1817                plait_sections.append((p1_start_x, p2_start_x))
1818                plait_sections.append((p1_end_x, p2_end_x))
1819        for sec in self.iter_offset_sections():
1820            if not sec.is_over and sec.is_overlap:
1821                plait_sections.append((sec.start, sec.end))
1822
1823        graph_edges = [(r[0].id, r[1].id) for r in plait_sections]
1824        cycles = get_cycles(graph_edges)
1825        plaits = []
1826        count = 0
1827        for cycle in cycles:
1828            cycle = connected_pairs(cycle)
1829            dup = cycle[1:]
1830            plait = [cycle[0][0], cycle[0][1]]
1831            for _ in range(len(cycle) - 1):
1832                last = plait[-1]
1833                for edge in dup:
1834                    if edge[0] == last:
1835                        plait.append(edge[1])
1836                        dup.remove(edge)
1837                        break
1838                    if edge[1] == last:
1839                        plait.append(edge[0])
1840                        dup.remove(edge)
1841                        break
1842            plaits.append(plait)
1843            count += 1
1844        d_x = self.d_intersections
1845        for plait in plaits:
1846            intersections = [d_x[x] for x in plait]
1847            vertices = [x.point for x in intersections]
1848            if not right_handed(vertices):
1849                vertices.reverse()
1850                plait.reverse()
1851            shape = Shape(vertices)
1852            shape.intersections = intersections
1853            shape.fill_color = colors.gold
1854            shape.inner_lines = None
1855            shape.subtype = Types.PLAIT
1856            self.plaits.append(shape)

Populate the self.plaits list with Plait objects. Plaits are optional for drawing. They form the under/over interlacing. They are created if the "with_plaits" argument is set to be True in the constructor. with_plaits is True by default but this can be changed by setting the auto_plaits value to False in the settings.py This method can be called by the user to create the plaits after the creation of the Lace object if they were not created initally.

  • Can be called by users.

Arguments:

None

Return:

None

Side Effects:

* self.plaits is populated with Plait objects.

Prerequisites:

self.polyline and self.parallel_poly_list must be populated.
self.divisions and self.intersections must be populated.
self.overlaps must be populated.

Where used:

Lace.__init__

Notes:

This method is called by the Lace constructor.  It is not
for users to call directly. Without this method, the Lace
object cannot be created.
def fragment_edge_graph(self) -> networkx.classes.graph.Graph:
2003    def fragment_edge_graph(self) -> nx.Graph:
2004        """
2005        Return a networkx graph of the connected fragments.
2006        If two fragments have a "common" division then they are connected.
2007
2008        Returns:
2009            nx.Graph: Graph of connected fragments.
2010        """
2011        G = nx.Graph()
2012        fragments = [(f.area, f) for f in self.fragments]
2013        fragments.sort(key=lambda x: x[0], reverse=True)
2014        for fragment in [f[1] for f in fragments[1:]]:
2015            for division in fragment.divisions:
2016                if division.twin and division.twin.fragment:
2017                    fragment2 = division.twin.fragment
2018                    G.add_node(fragment.id, fragment=fragment)
2019                    G.add_node(fragment2.id, fragment=fragment2)
2020                    G.add_edge(fragment.id, fragment2.id, edge=division)
2021        return G

Return a networkx graph of the connected fragments. If two fragments have a "common" division then they are connected.

Returns:

nx.Graph: Graph of connected fragments.

def fragment_vertex_graph(self) -> networkx.classes.graph.Graph:
2023    def fragment_vertex_graph(self) -> nx.Graph:
2024        """
2025        Return a networkx graph of the connected fragments.
2026        If two fragments have a "common" vertex then they are connected.
2027
2028        Returns:
2029            nx.Graph: Graph of connected fragments.
2030        """
2031        def get_neighbours(intersection):
2032            division = intersection.division
2033            if not division.next.twin:
2034                return None
2035            fragments = [division.fragment]
2036            start_division_id = division.id
2037            if division.next.twin:
2038                twin_id = division.next.twin.id
2039            else:
2040                return None
2041            while twin_id != start_division_id:
2042                if division.next.twin:
2043                    if division.fragment.id not in [x.id for x in fragments]:
2044                        fragments.append(division.fragment)
2045                    twin_id = division.next.twin.id
2046                    division = division.next.twin
2047                else:
2048                    if division.next.fragment.id not in [x.id for x in fragments]:
2049                        fragments.append(division.next.fragment)
2050                    break
2051
2052            return fragments
2053
2054        neighbours = []
2055        for fragment in self.fragments:
2056            for intersection in fragment.intersections:
2057                neighbours.append(get_neighbours(intersection))
2058        G = self.fragment_edge_graph()
2059        G2 = nx.Graph()
2060        for n in neighbours:
2061            if n:
2062                if len(n) > 2:
2063                    for pair in combinations(n, 2):
2064                        ids = tuple([x.id for x in pair])
2065                        if ids not in G.edges:
2066                            G2.add_node(ids[0], fragment=pair[0])
2067                            G2.add_node(ids[1], fragment=pair[1])
2068                            G2.add_edge(*ids)
2069        return G2

Return a networkx graph of the connected fragments. If two fragments have a "common" vertex then they are connected.

Returns:

nx.Graph: Graph of connected fragments.

def all_intersections( division_list: list[Division], d_intersections: dict[int, Intersection], d_connections: dict[frozenset, Intersection], loom=False) -> list[Intersection]:
2072def all_intersections(
2073    division_list: list[Division],
2074    d_intersections: dict[int, Intersection],
2075    d_connections: dict[frozenset, Intersection],
2076    loom=False,
2077) -> list[Intersection]:
2078    """
2079    Find all intersections of the given divisions. Sweep-line algorithm
2080    without a self-balancing tree. Instead of a self-balancing tree,
2081    it uses a numpy array to sort and filter the divisions. For the
2082    number of divisions that are commonly needed in a lace, this is
2083    sufficiently fast. It is also more robust and much easier to
2084    understand and debug. Tested with tens of thousands of divisions but
2085    not millions. The book has a section on this algorithm.
2086    simetri.geometry.py has another version (called
2087    all_intersections) for finding intersections among a given
2088    list of divisions.
2089
2090    Arguments:
2091    ----------
2092        division_list: list of Division objects.
2093
2094    Side Effects:
2095    -------------
2096        * Modifies the given division objects (in the division_list) in place
2097            by adding the intersections to the divisions' "intersections"
2098            attribute.
2099        * Updates the d_intersections
2100        * Updates the d_connections
2101
2102    Return:
2103    --------
2104        A list of all intersection objects among the given division
2105        list.
2106    """
2107    # register fake intersections at the endpoints of the open lines
2108    for division in division_list:
2109        if division.intersections:
2110            for x in division.intersections:
2111                d_intersections[x.id] = x
2112
2113    # All objects are assigned an integer id attribute when they are created
2114    division_array = array(
2115        [flatten(division.vertices) + [division.id] for division in division_list]
2116    )
2117    n_divisions = division_array.shape[0]  # number of divisions
2118    # precompute the min and max x and y values for each division
2119    # these will be used with the sweep line algorithm
2120    xmin = np.minimum(division_array[:, 0], division_array[:, 2]).reshape(
2121        n_divisions, 1
2122    )
2123    xmax = np.maximum(division_array[:, 0], division_array[:, 2]).reshape(
2124        n_divisions, 1
2125    )
2126    ymin = np.minimum(division_array[:, 1], division_array[:, 3]).reshape(
2127        n_divisions, 1
2128    )
2129    ymax = np.maximum(division_array[:, 1], division_array[:, 3]).reshape(
2130        n_divisions, 1
2131    )
2132    division_array = np.concatenate((division_array, xmin, ymin, xmax, ymax), 1)
2133    d_divisions = {}
2134    for division in division_list:
2135        d_divisions[division.id] = division
2136    i_id, i_xmin, i_ymin, i_xmax, i_ymax = range(4, 9)  # column indices
2137    # sort by xmin values
2138    division_array = division_array[division_array[:, i_xmin].argsort()]
2139    intersections = []
2140    for i in range(n_divisions):
2141        x1, y1, x2, y2, id1, sl_xmin, sl_ymin, sl_xmax, sl_ymax = division_array[i, :]
2142        division1_vertices = [x1, y1, x2, y2]
2143        start = i + 1  # search should start from the next division
2144        # filter the array by checking if the divisions' bounding-boxes are
2145        # overlapping with the bounding-box of the current division
2146        candidates = division_array[start:, :][
2147            (
2148                (
2149                    (division_array[start:, i_xmax] >= sl_xmin)
2150                    & (division_array[start:, i_xmin] <= sl_xmax)
2151                )
2152                & (
2153                    (division_array[start:, i_ymax] >= sl_ymin)
2154                    & (division_array[start:, i_ymin] <= sl_ymax)
2155                )
2156            )
2157        ]
2158        for candid in candidates:
2159            id2 = candid[i_id]
2160            division2_vertices = candid[:4]
2161            if loom:
2162                x1, y1 = division1_vertices[:2]
2163                x2, y2 = division2_vertices[:2]
2164                if x1 == x2 or y1 == y2:
2165                    continue
2166            connection_type, x_point = intersection2(
2167                *division1_vertices, *division2_vertices
2168            )
2169            if connection_type not in [
2170                Connection.DISJOINT,
2171                Connection.NONE,
2172                Connection.PARALLEL,
2173            ]:
2174                division1, division2 = d_divisions[int(id1)], d_divisions[int(id2)]
2175                x_point__e1_2 = frozenset((division1.id, division2.id))
2176                if x_point__e1_2 not in d_connections:
2177                    inters_obj = Intersection(x_point, division1, division2)
2178                    d_intersections[inters_obj.id] = inters_obj
2179                    d_connections[x_point__e1_2] = inters_obj
2180                    division1.intersections.append(inters_obj)
2181                    division2.intersections.append(inters_obj)
2182                    intersections.append(inters_obj)
2183
2184    for division in division_list:
2185        division._sort_intersections()
2186    return intersections

Find all intersections of the given divisions. Sweep-line algorithm without a self-balancing tree. Instead of a self-balancing tree, it uses a numpy array to sort and filter the divisions. For the number of divisions that are commonly needed in a lace, this is sufficiently fast. It is also more robust and much easier to understand and debug. Tested with tens of thousands of divisions but not millions. The book has a section on this algorithm. simetri.geometry.py has another version (called all_intersections) for finding intersections among a given list of divisions.

Arguments:

division_list: list of Division objects.

Side Effects:

* Modifies the given division objects (in the division_list) in place
    by adding the intersections to the divisions' "intersections"
    attribute.
* Updates the d_intersections
* Updates the d_connections

Return:

A list of all intersection objects among the given division
list.
def merge_nodes( division_list: list[Division], d_intersections: dict[int, Intersection], d_connections: dict[frozenset, Intersection], loom=False) -> list[Intersection]:
2189def merge_nodes(
2190    division_list: list[Division],
2191    d_intersections: dict[int, Intersection],
2192    d_connections: dict[frozenset, Intersection],
2193    loom=False,
2194) -> list[Intersection]:
2195    """
2196    Find all intersections of the given divisions. Sweep-line algorithm
2197    without a self-balancing tree. Instead of a self-balancing tree,
2198    it uses a numpy array to sort and filter the divisions. For the
2199    number of divisions that are commonly needed in a lace, this is
2200    sufficiently fast. It is also more robust and much easier to
2201    understand and debug. Tested with tens of thousands of divisions but
2202    not millions. The book has a section on this algorithm.
2203    simetri.geometry.py has another version (called
2204    all_intersections) for finding intersections among a given
2205    list of divisions.
2206
2207    Arguments:
2208    ----------
2209        division_list: list of division objects.
2210
2211    Side Effects:
2212    -------------
2213        * Modifies the given division objects (in the division_list) in place
2214            by adding the intersections to the divisions' "intersections"
2215            attribute.
2216        * Updates the d_intersections
2217        * Updates the d_connections
2218
2219    Return:
2220    --------
2221        A list of all intersection objects among the given division
2222        list.
2223    """
2224    # register fake intersections at the endpoints of the open lines
2225    for division in division_list:
2226        if division.intersections:
2227            for x in division.intersections:
2228                d_intersections[x.id] = x
2229
2230    # All objects are assigned an integer id attribute when they are created
2231    division_array = array(
2232        [flatten(division.vertices) + [division.id] for division in division_list]
2233    )
2234    n_divisions = division_array.shape[0]  # number of divisions
2235    # precompute the min and max x and y values for each division
2236    # these will be used with the sweep line algorithm
2237    xmin = np.minimum(division_array[:, 0], division_array[:, 2]).reshape(
2238        n_divisions, 1
2239    )
2240    xmax = np.maximum(division_array[:, 0], division_array[:, 2]).reshape(
2241        n_divisions, 1
2242    )
2243    ymin = np.minimum(division_array[:, 1], division_array[:, 3]).reshape(
2244        n_divisions, 1
2245    )
2246    ymax = np.maximum(division_array[:, 1], division_array[:, 3]).reshape(
2247        n_divisions, 1
2248    )
2249    division_array = np.concatenate((division_array, xmin, ymin, xmax, ymax), 1)
2250    d_divisions = {}
2251    for division in division_list:
2252        d_divisions[division.id] = division
2253    i_id, i_xmin, i_ymin, i_xmax, i_ymax = range(4, 9)  # column indices
2254    # sort by xmin values
2255    division_array = division_array[division_array[:, i_xmin].argsort()]
2256    intersections = []
2257    for i in range(n_divisions):
2258        x1, y1, x2, y2, id1, sl_xmin, sl_ymin, sl_xmax, sl_ymax = division_array[i, :]
2259        division1_vertices = [x1, y1, x2, y2]
2260        start = i + 1  # search should start from the next division
2261        # filter the array by checking if the divisions' bounding-boxes are
2262        # overlapping with the bounding-box of the current division
2263        candidates = division_array[start:, :][
2264            (
2265                (
2266                    (division_array[start:, i_xmax] >= sl_xmin)
2267                    & (division_array[start:, i_xmin] <= sl_xmax)
2268                )
2269                & (
2270                    (division_array[start:, i_ymax] >= sl_ymin)
2271                    & (division_array[start:, i_ymin] <= sl_ymax)
2272                )
2273            )
2274        ]
2275        for candid in candidates:
2276            id2 = candid[i_id]
2277            division2_vertices = candid[:4]
2278            if loom:
2279                x1, y1 = division1_vertices[:2]
2280                x2, y2 = division2_vertices[:2]
2281                if x1 == x2 or y1 == y2:
2282                    continue
2283            connection_type, x_point = intersection2(
2284                *division1_vertices, *division2_vertices
2285            )
2286            if connection_type not in [
2287                Connection.DISJOINT,
2288                Connection.NONE,
2289                Connection.PARALLEL,
2290            ]:
2291                division1, division2 = d_divisions[int(id1)], d_divisions[int(id2)]
2292                x_point__e1_2 = frozenset((division1.id, division2.id))
2293                if x_point__e1_2 not in d_connections:
2294                    inters_obj = Intersection(x_point, division1, division2)
2295                    d_intersections[inters_obj.id] = inters_obj
2296                    d_connections[x_point__e1_2] = inters_obj
2297                    division1.intersections.append(inters_obj)
2298                    division2.intersections.append(inters_obj)
2299                    intersections.append(inters_obj)
2300
2301    for division in division_list:
2302        division._sort_intersections()
2303    return intersections

Find all intersections of the given divisions. Sweep-line algorithm without a self-balancing tree. Instead of a self-balancing tree, it uses a numpy array to sort and filter the divisions. For the number of divisions that are commonly needed in a lace, this is sufficiently fast. It is also more robust and much easier to understand and debug. Tested with tens of thousands of divisions but not millions. The book has a section on this algorithm. simetri.geometry.py has another version (called all_intersections) for finding intersections among a given list of divisions.

Arguments:

division_list: list of division objects.

Side Effects:

* Modifies the given division objects (in the division_list) in place
    by adding the intersections to the divisions' "intersections"
    attribute.
* Updates the d_intersections
* Updates the d_connections

Return:

A list of all intersection objects among the given division
list.