simetri.graphics.shape

Let the IDEs know about the dynamically created attributes of the Shape class.

  1"""Shape objects are the main geometric entities in Simetri.
  2They are created by providing a sequence of points (a list of (x, y) coordinates).
  3If a style argument (a ShapeStyle object) is provided, then the style attributes
  4of this ShapeStyle object will superseed the style attributes of the Shape object.
  5"""
  6
  7__all__ = ["Shape", "custom_attributes"]
  8
  9import warnings
 10from typing import Sequence, Union, List, Tuple
 11
 12import numpy as np
 13from numpy import array, allclose
 14from numpy.linalg import inv
 15import networkx as nx
 16from typing_extensions import Self
 17
 18from .affine import identity_matrix
 19from .all_enums import *
 20from .bbox import BoundingBox
 21from ..canvas.style_map import ShapeStyle, shape_style_map, shape_args
 22from ..helpers.validation import validate_args
 23from .common import Point, common_properties, Line
 24from ..settings.settings import defaults
 25from ..helpers.utilities import (
 26    get_transform,
 27    is_nested_sequence,
 28    decompose_transformations,
 29)
 30from ..geometry.geometry import (
 31    homogenize,
 32    right_handed,
 33    all_intersections,
 34    polygon_area,
 35    polyline_length,
 36    close_points2,
 37    connected_pairs,
 38    check_consecutive_duplicates
 39)
 40from ..helpers.graph import Node, Graph, GraphEdge
 41from .core import Base, StyleMixin
 42from .bbox import bounding_box
 43from .points import Points
 44from .batch import Batch
 45
 46
 47class Shape(Base, StyleMixin):
 48    """The main class for all geometric entities in Simetri.
 49
 50     A Shape is created by providing a sequence of points (a sequence of (x, y) coordinates).
 51    If a style argument (a ShapeStyle object) is provided, then its style attributes override
 52    the default values the Shape object would assign. Additional attributes (e.g. line_width, fill_color, line_style)
 53    may be provided.
 54
 55    """
 56
 57    def __init__(
 58        self,
 59        points: Sequence[Point] = None,
 60        closed: bool = False,
 61        xform_matrix: np.array = None,
 62        **kwargs,
 63    ) -> None:
 64        """Initialize a Shape object.
 65
 66        Args:
 67            points (Sequence[Point], optional): The points that make up the shape.
 68            closed (bool, optional): Whether the shape is closed. Defaults to False.
 69            xform_matrix (np.array, optional): The transformation matrix. Defaults to None.
 70            **kwargs (dict): Additional attributes for the shape.
 71
 72        Raises:
 73            ValueError: If the provided subtype is not valid.
 74        """
 75
 76        if check_consecutive_duplicates(points):
 77            err_msg = "Consecutive duplicate points are not allowed in Shape objects."
 78            if not defaults["allow_consec_dup_points"]:
 79                err_msg += ' Set sg.defaults["allow_consec_dup_points"] to True if you really, really have to avoid this error.'
 80                raise ValueError(err_msg)
 81            warnings.warn(err_msg, UserWarning)
 82
 83        self.__dict__["style"] = ShapeStyle()
 84        self.__dict__["_style_map"] = shape_style_map
 85        self._set_aliases()
 86        valid_args = shape_args
 87        validate_args(kwargs, valid_args)
 88        if "subtype" in kwargs:
 89            if kwargs["subtype"] not in shape_types:
 90                raise ValueError(f"Invalid subtype: {kwargs['subtype']}")
 91            self.subtype = kwargs["subtype"]
 92            kwargs.pop("subtype")
 93        else:
 94            self.subtype = Types.SHAPE
 95
 96        if "dist_tol" in kwargs:
 97            self.dist_tol = kwargs["dist_tol"]
 98            self.dist_tol2 = self.dist_tol**2
 99            kwargs.pop("dist_tol")
100        else:
101            self.dist_tol = defaults["dist_tol"]
102            self.dist_tol2 = self.dist_tol**2
103
104        if points is None:
105            self.primary_points = Points()
106            self.closed = False
107        else:
108            self.closed, points = self._get_closed(points, closed)
109            self.primary_points = Points(points)
110        self.xform_matrix = get_transform(xform_matrix)
111        self.type = Types.SHAPE
112        for key, value in kwargs.items():
113            setattr(self, key, value)
114
115        self._b_box = None
116        common_properties(self)
117
118    def __setattr__(self, name, value):
119        """Set an attribute of the shape.
120
121        Args:
122            name (str): The name of the attribute.
123            value (Any): The value to set.
124        """
125        super().__setattr__(name, value)
126
127    def __getattr__(self, name):
128        """Retrieve an attribute of the shape.
129
130        Args:
131            name (str): The attribute name to return.
132
133        Returns:
134            Any: The value of the attribute.
135
136        Raises:
137            AttributeError: If the attribute cannot be found.
138        """
139        try:
140            res = super().__getattr__(name)
141        except AttributeError:
142            res = self.__dict__[name]
143        return res
144
145    def _get_closed(self, points: Sequence[Point], closed: bool):
146        """Determine whether the shape should be considered closed.
147
148        Args:
149            points (Sequence[Point]): The points that define the shape.
150            closed (bool): The user-specified closed flag.
151
152        Returns:
153            tuple: A tuple consisting of:
154                - bool: True if the shape is closed, False otherwise.
155                - list: The (possibly modified) list of points.
156        """
157        decision_table = {
158            (True, True): True,
159            (True, False): True,
160            (False, True): True,
161            (False, False): False,
162        }
163        n = len(points)
164        if n < 3:
165            res = False
166        else:
167            points = [tuple(x[:2]) for x in points]
168            polygon = self._is_polygon(points)
169            res = decision_table[(bool(closed), polygon)]
170            if polygon:
171                points.pop()
172        return res, points
173
174    def __len__(self):
175        """Return the number of points in the shape.
176
177        Returns:
178            int: The number of primary points.
179        """
180        return len(self.primary_points)
181
182    def __str__(self):
183        """Return a string representation of the shape.
184
185        Returns:
186            str: A string representation of the shape.
187        """
188        if len(self.primary_points) == 0:
189            res = "Shape()"
190        elif len(self.primary_points) < 4:
191            res = f"Shape({self.vertices})"
192        else:
193            res = f"Shape([{self.vertices[0]}, ..., {self.vertices[-1]}])"
194        return res
195
196    def __repr__(self):
197        """Return a string representation of the shape.
198
199        Returns:
200            str: A string representation of the shape.
201        """
202        return self.__str__()
203
204    def __getitem__(self, subscript: Union[int, slice]):
205        """Retrieve point(s) from the shape by index or slice.
206
207        Args:
208            subscript (int or slice): The index or slice specifying the point(s) to retrieve.
209
210        Returns:
211            Point or list[Point]: The requested point or list of points (after applying the transformation).
212
213        Raises:
214            TypeError: If the subscript type is invalid.
215        """
216        if isinstance(subscript, slice):
217            coords = self.primary_points.homogen_coords
218            res = list(
219                coords[subscript.start : subscript.stop : subscript.step]
220                @ self.xform_matrix
221            )
222        else:
223            res = self.primary_points.homogen_coords[subscript] @ self.xform_matrix
224        return res
225
226    def __setitem__(self, subscript, value):
227        """Set the point(s) at the given subscript.
228
229        Args:
230            subscript (int or slice): The subscript to set the point(s) at.
231            value (Point or list[Point]): The value to set the point(s) to.
232
233        Raises:
234            TypeError: If the subscript type is invalid.
235        """
236        if isinstance(subscript, slice):
237            if is_nested_sequence(value):
238                value = homogenize(value) @ inv(self.xform_matrix)
239            else:
240                value = homogenize([value]) @ inv(self.xform_matrix)
241            self.primary_points[subscript.start : subscript.stop : subscript.step] = [
242                tuple(x[:2]) for x in value
243            ]
244        elif isinstance(subscript, int):
245            value = homogenize([value]) @ inv(self.xform_matrix)
246            self.primary_points[subscript] = tuple(value[0][:2])
247        else:
248            raise TypeError("Invalid subscript type")
249
250    def __delitem__(self, subscript) -> Self:
251        """Delete the point(s) at the given subscript.
252
253        Args:
254            subscript (int or slice): The subscript to delete the point(s) from.
255        """
256        del self.primary_points[subscript]
257
258    def index(self, point: Point, atol=None) -> int:
259        """Return the index of the given point.
260
261        Args:
262            point (Point): The point to find the index of.
263
264        Returns:
265            int: The index of the point.
266        """
267        point = tuple(point[:2])
268
269        if atol is None:
270            atol = defaults["atol"]
271        ind = np.where((np.isclose(self.vertices, point, atol=atol)).all(axis=1))[0][0]
272
273        return ind
274
275    def remove(self, point: Point) -> Self:
276        """Remove a point from the shape.
277
278        Args:
279            point (Point): The point to remove.
280        """
281        ind = self.vertices.index(point)
282        self.primary_points.pop(ind)
283
284        return self
285
286    def append(self, point: Point) -> Self:
287        """Append a point to the shape.
288
289        Args:
290            point (Point): The point to append.
291        """
292        point = homogenize([point]) @ inv(self.xform_matrix)
293        self.primary_points.append(tuple(point[0][:2]))
294
295    def insert(self, index: int, point: Point) -> Self:
296        """Insert a point at a given index.
297
298        Args:
299            index (int): The index to insert the point at.
300            point (Point): The point to insert.
301        """
302        point = homogenize([point]) @ inv(self.xform_matrix)
303        self.primary_points.insert(index, tuple(point[0][:2]))
304
305        return self
306
307    def extend(self, points: Sequence[Point]) -> Self:
308        """Extend the shape with a list of points.
309
310        Args:
311            values (list[Point]): The points to extend the shape with.
312        """
313        homogenized = homogenize(points) @ inv(self.xform_matrix)
314        self.primary_points.extend([tuple(x[:2]) for x in homogenized])
315
316        return self
317
318    def pop(self, index: int = -1) -> Point:
319        """Pop a point from the shape.
320
321        Args:
322            index (int, optional): The index to pop the point from, defaults to -1.
323
324        Returns:
325            Point: The popped point.
326        """
327        point = self.vertices[index]
328        self.primary_points.pop(index)
329
330        return point
331
332    def __iter__(self):
333        """Return an iterator over the vertices of the shape.
334
335        Returns:
336            Iterator[Point]: An iterator over the vertices of the shape.
337        """
338        return iter(self.vertices)
339
340    def _update(self, xform_matrix: array, reps: int = 0) -> Batch:
341        """Used internally. Update the shape with a transformation matrix.
342
343        Args:
344            xform_matrix (array): The transformation matrix.
345            reps (int, optional): The number of repetitions, defaults to 0.
346
347        Returns:
348            Batch: The updated shape or a batch of shapes.
349        """
350        if reps == 0:
351            fillet_radius = self.fillet_radius
352            if fillet_radius:
353                scale = max(decompose_transformations(xform_matrix)[2])
354                self.fillet_radius = fillet_radius * scale
355            self.xform_matrix = self.xform_matrix @ xform_matrix
356            res = self
357        else:
358            shapes = [self]
359            shape = self
360            for _ in range(reps):
361                shape = shape.copy()
362                shape._update(xform_matrix)
363                shapes.append(shape)
364            res = Batch(shapes)
365        return res
366
367    def __eq__(self, other):
368        """Check if the shape is equal to another shape.
369
370        Args:
371            other (Shape): The other shape to compare to.
372
373        Returns:
374            bool: True if the shapes are equal, False otherwise.
375        """
376        if not hasattr(other, "type"):
377            return False
378        if other.type != Types.SHAPE:
379            return False
380
381        len1 = len(self)
382        len2 = len(other)
383        if len1 == 0 and len2 == 0:
384            res = True
385        elif len1 == 0 or len2 == 0:
386            res = False
387        elif isinstance(other, Shape) and len1 == len2:
388            res = allclose(
389                self.xform_matrix,
390                other.xform_matrix,
391                rtol=defaults["rtol"],
392                atol=defaults["atol"],
393            ) and allclose(self.primary_points.nd_array, other.primary_points.nd_array)
394        else:
395            res = False
396
397        return res
398
399    def __bool__(self):
400        """Return whether the shape has any points.
401
402        Returns:
403            bool: True if the shape has points, False otherwise.
404        """
405        return len(self.primary_points) > 0
406
407    def topology(self) -> Topology:
408        """Return info about the topology of the shape.
409
410        Returns:
411            set: A set of topology values.
412        """
413        t_map = {
414            "WITHIN": Topology.FOLDED,
415            "CONTAINS": Topology.FOLDED,
416            "COLL_CHAIN": Topology.COLLINEAR,
417            "YJOINT": Topology.YJOINT,
418            "CHAIN": Topology.SIMPLE,
419            "CONGRUENT": Topology.CONGRUENT,
420            "INTERSECT": Topology.INTERSECTING,
421        }
422        intersections = all_intersections(self.vertex_pairs, use_intersection3=True)
423        connections = []
424        for val in intersections.values():
425            connections.extend([x[0].value for x in val])
426        connections = set(connections)
427        topology = set((t_map[x] for x in connections))
428
429        if len(topology) > 1 and Topology.SIMPLE in topology:
430            topology.discard(Topology.SIMPLE)
431
432        return topology
433
434    def merge(self, other, dist_tol: float = None) -> Union[Self, None]:
435        """Merge two shapes if they are connected. Does not work for polygons.
436        Only polyline shapes can be merged together.
437
438        Args:
439            other (Shape): The other shape to merge with.
440            dist_tol (float, optional): The distance tolerance for merging, defaults to None.
441
442        Returns:
443            Shape or None: The merged shape or None if the shapes cannot be merged.
444        """
445        if dist_tol is None:
446            dist_tol = defaults["dist_tol"]
447
448        if self.closed or other.closed or self.is_polygon or other.is_polygon:
449            res = None
450        else:
451            vertices = self._chain_vertices(
452                self.as_list(), other.as_list(), dist_tol=dist_tol
453            )
454            if vertices:
455                closed = close_points2(vertices[0], vertices[-1], dist2=self.dist_tol2)
456                res = Shape(vertices, closed=closed)
457            else:
458                res = None
459
460        return res
461
462    def connect(self, other) -> Self:
463        """Connect two shapes by adding the other shape's vertices to self.
464
465        Args:
466            other (Shape): The other shape to connect.
467        """
468        self.extend(other.vertices)
469
470        return self
471
472    def _chain_vertices(
473        self, verts1: Sequence[Point], verts2: Sequence[Point], dist_tol: float = None
474    ) -> Union[List[Point], None]:
475        """Chain two sets of vertices if they are connected.
476
477        Args:
478            verts1 (list[Point]): The first set of vertices.
479            verts2 (list[Point]): The second set of vertices.
480            dist_tol (float, optional): The distance tolerance for chaining, defaults to None.
481
482        Returns:
483            list[Point] or None: The chained vertices or None if the vertices cannot be chained.
484        """
485        dist_tol2 = dist_tol * dist_tol
486        start1, end1 = verts1[0], verts1[-1]
487        start2, end2 = verts2[0], verts2[-1]
488        same_starts = close_points2(start1, start2, dist2=dist_tol2)
489        same_ends = close_points2(end1, end2, dist2=self.dist_tol2)
490        if same_starts and same_ends:
491            res = verts1
492        elif close_points2(end1, start2, dist2=self.dist_tol2):
493            verts2.pop(0)
494        elif close_points2(start1, end2, dist2=self.dist_tol2):
495            verts2.reverse()
496            verts1.reverse()
497            verts2.pop(0)
498        elif same_starts:
499            verts2.reverse()
500            verts2.pop(-1)
501            start = verts2[:]
502            end = verts1[:]
503            verts1 = start
504            verts2 = end
505        elif same_ends:
506            verts2.reverse()
507            verts2.pop(0)
508        else:
509            return None
510        if same_starts and same_ends:
511            all_verts = verts1 + verts2
512            if not right_handed(all_verts):
513                all_verts.reverse()
514            res = all_verts
515        else:
516            res = verts1 + verts2
517
518        return res
519
520    def _is_polygon(self, vertices: Sequence[Point]) -> bool:
521        """Return True if the vertices form a polygon.
522
523        Args:
524            vertices (list[Point]): The vertices to check.
525
526        Returns:
527            bool: True if the vertices form a polygon, False otherwise.
528        """
529        return close_points2(vertices[0][:2], vertices[-1][:2], dist2=self.dist_tol2)
530
531    def as_graph(self, directed=False, weighted=False, n_round=None) -> nx.Graph:
532        """Return the shape as a graph object.
533
534        Args:
535            directed (bool, optional): Whether the graph is directed, defaults to False.
536            weighted (bool, optional): Whether the graph is weighted, defaults to False.
537            n_round (int, optional): The number of decimal places to round to, defaults to None.
538
539        Returns:
540            Graph: The graph object.
541        """
542        if n_round is None:
543            n_round = defaults["n_round"]
544        vertices = [(round(v[0], n_round), round(v[1], n_round)) for v in self.vertices]
545        points = [Node(*n) for n in vertices]
546        pairs = connected_pairs(points)
547        edges = [GraphEdge(p[0], p[1]) for p in pairs]
548        if self.closed:
549            edges.append(GraphEdge(points[-1], points[0]))
550
551        if directed:
552            nx_graph = nx.DiGraph()
553            graph_type = Types.DIRECTED
554        else:
555            nx_graph = nx.Graph()
556            graph_type = Types.UNDIRECTED
557
558        for point in points:
559            nx_graph.add_node(point.id, point=point)
560
561        if weighted:
562            for edge in edges:
563                nx_graph.add_edge(edge.start.id, edge.end.id, weight=edge.length)
564            subtype = Types.WEIGHTED
565        else:
566            id_pairs = [(e.start.id, e.end.id) for e in edges]
567            nx_graph.add_edges_from(id_pairs)
568            subtype = Types.NONE
569        pairs = [(e.start.id, e.end.id) for e in edges]
570        try:
571            cycles = nx.cycle_basis(nx_graph)
572        except nx.exception.NetworkXNoCycle:
573            cycles = None
574
575        if cycles:
576            n = len(cycles)
577            for cycle in cycles:
578                cycle.append(cycle[0])
579            if n == 1:
580                cycle = cycles[0]
581        else:
582            cycles = None
583        graph = Graph(type=graph_type, subtype=subtype, nx_graph=nx_graph)
584        return graph
585
586    def as_array(self, homogeneous=False) -> np.ndarray:
587        """Return the vertices as an array.
588
589        Args:
590            homogeneous (bool, optional): Whether to return homogeneous coordinates, defaults to False.
591
592        Returns:
593            ndarray: The vertices as an array.
594        """
595        if homogeneous:
596            res = self.primary_points.nd_array @ self.xform_matrix
597        else:
598            res = array(self.vertices)
599        return res
600
601    def as_list(self) -> List[Point]:
602        """Return the vertices as a list of tuples.
603
604        Returns:
605            list[tuple]: The vertices as a list of tuples.
606        """
607        return list(self.vertices)
608
609    @property
610    def final_coords(self) -> np.ndarray:
611        """The final coordinates of the shape. primary_points @ xform_matrix.
612
613        Returns:
614            ndarray: The final coordinates of the shape.
615        """
616        if self.primary_points:
617            res = self.primary_points.homogen_coords @ self.xform_matrix
618        else:
619            res = []
620
621        return res
622
623    @property
624    def vertices(self) -> Tuple[Point]:
625        """The final coordinates of the shape.
626
627        Returns:
628            tuple: The final coordinates of the shape.
629        """
630        if self.primary_points:
631            res = tuple(((x[0], x[1]) for x in (self.final_coords[:, :2])))
632        else:
633            res = []
634
635        return res
636
637    @property
638    def vertex_pairs(self) -> List[Tuple[Point, Point]]:
639        """Return a list of connected pairs of vertices.
640
641        Returns:
642            list[tuple[Point, Point]]: A list of connected pairs of vertices.
643        """
644        vertices = list(self.vertices)
645        if self.closed:
646            vertices.append(vertices[0])
647        return connected_pairs(vertices)
648
649    @property
650    def orig_coords(self) -> np.ndarray:
651        """The primary points in homogeneous coordinates.
652
653        Returns:
654            ndarray: The primary points in homogeneous coordinates.
655        """
656        return self.primary_points.homogen_coords
657
658    @property
659    def b_box(self) -> BoundingBox:
660        """Return the bounding box of the shape.
661
662        Returns:
663            BoundingBox: The bounding box of the shape.
664        """
665        if self.primary_points:
666            self._b_box = bounding_box(self.final_coords)
667        else:
668            self._b_box = bounding_box([(0, 0)])
669        return self._b_box
670
671    @property
672    def area(self) -> float:
673        """Return the area of the shape.
674
675        Returns:
676            float: The area of the shape.
677        """
678        if self.closed:
679            vertices = self.vertices[:]
680            if not close_points2(vertices[0], vertices[-1], dist2=self.dist_tol2):
681                vertices = list(vertices) + [vertices[0]]
682            res = polygon_area(vertices)
683        else:
684            res = 0
685
686        return res
687
688    @property
689    def total_length(self) -> float:
690        """Return the total length of the shape.
691
692        Returns:
693            float: The total length of the shape.
694        """
695        return polyline_length(self.vertices[:-1], self.closed)
696
697    @property
698    def is_polygon(self) -> bool:
699        """Return True if 'closed'.
700
701        Returns:
702            bool: True if the shape is closed, False otherwise.
703        """
704        return self.closed
705
706    def clear(self) -> Self:
707        """Clear all points and reset the style attributes.
708
709        Returns:
710            None
711        """
712        self.primary_points = Points()
713        self.xform_matrix = identity_matrix()
714        self.style = ShapeStyle()
715        self._set_aliases()
716        self._b_box = None
717
718        return self
719
720    def count(self, point: Point) -> int:
721        """Return the number of times the point is found in the shape.
722
723        Args:
724            point (Point): The point to count.
725
726        Returns:
727            int: The number of times the point is found in the shape.
728        """
729        verts = self.orig_coords @ self.xform_matrix
730        verts = verts[:, :2]
731        n = verts.shape[0]
732        point = array(point[:2])
733        values = np.tile(point, (n, 1))
734        col1 = (verts[:, 0] - values[:, 0]) ** 2
735        col2 = (verts[:, 1] - values[:, 1]) ** 2
736        distances = col1 + col2
737
738        return np.count_nonzero(distances <= self.dist_tol2)
739
740    def copy(self) -> "Shape":
741        """Return a copy of the shape.
742
743        Returns:
744            Shape: A copy of the shape.
745        """
746        if self.primary_points.coords:
747            points = self.primary_points.copy()
748        else:
749            points = []
750        shape = Shape(
751            points,
752            xform_matrix=self.xform_matrix,
753            closed=self.closed,
754            marker_type=self.marker_type,
755        )
756        for attrib in shape_style_map:
757            setattr(shape, attrib, getattr(self, attrib))
758        shape.subtype = self.subtype
759        custom_attribs = custom_attributes(self)
760        for attrib in custom_attribs:
761            setattr(shape, attrib, getattr(self, attrib))
762
763        return shape
764
765    @property
766    def edges(self) -> List[Line]:
767        """Return a list of edges.
768
769        Edges are represented as tuples of points:
770        edge: ((x1, y1), (x2, y2))
771        edges: [((x1, y1), (x2, y2)), ((x2, y2), (x3, y3)), ...]
772
773        Returns:
774            list[tuple[Point, Point]]: A list of edges.
775        """
776        vertices = list(self.vertices[:])
777        if self.closed:
778            vertices.append(vertices[0])
779
780        return connected_pairs(vertices)
781
782    @property
783    def segments(self) -> List[Line]:
784        """Return a list of edges.
785
786        Edges are represented as tuples of points:
787        edge: ((x1, y1), (x2, y2))
788        edges: [((x1, y1), (x2, y2)), ((x2, y2), (x3, y3)), ...]
789
790        Returns:
791            list[tuple[Point, Point]]: A list of edges.
792        """
793
794        return self.edges
795
796    def reverse(self) -> Self:
797        """Reverse the order of the vertices.
798
799        Returns:
800            None
801        """
802        self.primary_points.reverse()
803
804        return self
805
806
807def custom_attributes(item: Shape) -> List[str]:
808    """Return a list of custom attributes of a Shape or Batch instance.
809
810    Args:
811        item (Shape): The Shape or Batch instance.
812
813    Returns:
814        list[str]: A list of custom attribute names.
815
816    Raises:
817        TypeError: If the item is not a Shape instance.
818    """
819    if isinstance(item, Shape):
820        dummy = Shape([(0, 0), (1, 0)])
821    else:
822        raise TypeError("Invalid item type")
823    native_attribs = set(dir(dummy))
824    custom_attribs = set(dir(item)) - native_attribs
825
826    return list(custom_attribs)
 48class Shape(Base, StyleMixin):
 49    """The main class for all geometric entities in Simetri.
 50
 51     A Shape is created by providing a sequence of points (a sequence of (x, y) coordinates).
 52    If a style argument (a ShapeStyle object) is provided, then its style attributes override
 53    the default values the Shape object would assign. Additional attributes (e.g. line_width, fill_color, line_style)
 54    may be provided.
 55
 56    """
 57
 58    def __init__(
 59        self,
 60        points: Sequence[Point] = None,
 61        closed: bool = False,
 62        xform_matrix: np.array = None,
 63        **kwargs,
 64    ) -> None:
 65        """Initialize a Shape object.
 66
 67        Args:
 68            points (Sequence[Point], optional): The points that make up the shape.
 69            closed (bool, optional): Whether the shape is closed. Defaults to False.
 70            xform_matrix (np.array, optional): The transformation matrix. Defaults to None.
 71            **kwargs (dict): Additional attributes for the shape.
 72
 73        Raises:
 74            ValueError: If the provided subtype is not valid.
 75        """
 76
 77        if check_consecutive_duplicates(points):
 78            err_msg = "Consecutive duplicate points are not allowed in Shape objects."
 79            if not defaults["allow_consec_dup_points"]:
 80                err_msg += ' Set sg.defaults["allow_consec_dup_points"] to True if you really, really have to avoid this error.'
 81                raise ValueError(err_msg)
 82            warnings.warn(err_msg, UserWarning)
 83
 84        self.__dict__["style"] = ShapeStyle()
 85        self.__dict__["_style_map"] = shape_style_map
 86        self._set_aliases()
 87        valid_args = shape_args
 88        validate_args(kwargs, valid_args)
 89        if "subtype" in kwargs:
 90            if kwargs["subtype"] not in shape_types:
 91                raise ValueError(f"Invalid subtype: {kwargs['subtype']}")
 92            self.subtype = kwargs["subtype"]
 93            kwargs.pop("subtype")
 94        else:
 95            self.subtype = Types.SHAPE
 96
 97        if "dist_tol" in kwargs:
 98            self.dist_tol = kwargs["dist_tol"]
 99            self.dist_tol2 = self.dist_tol**2
100            kwargs.pop("dist_tol")
101        else:
102            self.dist_tol = defaults["dist_tol"]
103            self.dist_tol2 = self.dist_tol**2
104
105        if points is None:
106            self.primary_points = Points()
107            self.closed = False
108        else:
109            self.closed, points = self._get_closed(points, closed)
110            self.primary_points = Points(points)
111        self.xform_matrix = get_transform(xform_matrix)
112        self.type = Types.SHAPE
113        for key, value in kwargs.items():
114            setattr(self, key, value)
115
116        self._b_box = None
117        common_properties(self)
118
119    def __setattr__(self, name, value):
120        """Set an attribute of the shape.
121
122        Args:
123            name (str): The name of the attribute.
124            value (Any): The value to set.
125        """
126        super().__setattr__(name, value)
127
128    def __getattr__(self, name):
129        """Retrieve an attribute of the shape.
130
131        Args:
132            name (str): The attribute name to return.
133
134        Returns:
135            Any: The value of the attribute.
136
137        Raises:
138            AttributeError: If the attribute cannot be found.
139        """
140        try:
141            res = super().__getattr__(name)
142        except AttributeError:
143            res = self.__dict__[name]
144        return res
145
146    def _get_closed(self, points: Sequence[Point], closed: bool):
147        """Determine whether the shape should be considered closed.
148
149        Args:
150            points (Sequence[Point]): The points that define the shape.
151            closed (bool): The user-specified closed flag.
152
153        Returns:
154            tuple: A tuple consisting of:
155                - bool: True if the shape is closed, False otherwise.
156                - list: The (possibly modified) list of points.
157        """
158        decision_table = {
159            (True, True): True,
160            (True, False): True,
161            (False, True): True,
162            (False, False): False,
163        }
164        n = len(points)
165        if n < 3:
166            res = False
167        else:
168            points = [tuple(x[:2]) for x in points]
169            polygon = self._is_polygon(points)
170            res = decision_table[(bool(closed), polygon)]
171            if polygon:
172                points.pop()
173        return res, points
174
175    def __len__(self):
176        """Return the number of points in the shape.
177
178        Returns:
179            int: The number of primary points.
180        """
181        return len(self.primary_points)
182
183    def __str__(self):
184        """Return a string representation of the shape.
185
186        Returns:
187            str: A string representation of the shape.
188        """
189        if len(self.primary_points) == 0:
190            res = "Shape()"
191        elif len(self.primary_points) < 4:
192            res = f"Shape({self.vertices})"
193        else:
194            res = f"Shape([{self.vertices[0]}, ..., {self.vertices[-1]}])"
195        return res
196
197    def __repr__(self):
198        """Return a string representation of the shape.
199
200        Returns:
201            str: A string representation of the shape.
202        """
203        return self.__str__()
204
205    def __getitem__(self, subscript: Union[int, slice]):
206        """Retrieve point(s) from the shape by index or slice.
207
208        Args:
209            subscript (int or slice): The index or slice specifying the point(s) to retrieve.
210
211        Returns:
212            Point or list[Point]: The requested point or list of points (after applying the transformation).
213
214        Raises:
215            TypeError: If the subscript type is invalid.
216        """
217        if isinstance(subscript, slice):
218            coords = self.primary_points.homogen_coords
219            res = list(
220                coords[subscript.start : subscript.stop : subscript.step]
221                @ self.xform_matrix
222            )
223        else:
224            res = self.primary_points.homogen_coords[subscript] @ self.xform_matrix
225        return res
226
227    def __setitem__(self, subscript, value):
228        """Set the point(s) at the given subscript.
229
230        Args:
231            subscript (int or slice): The subscript to set the point(s) at.
232            value (Point or list[Point]): The value to set the point(s) to.
233
234        Raises:
235            TypeError: If the subscript type is invalid.
236        """
237        if isinstance(subscript, slice):
238            if is_nested_sequence(value):
239                value = homogenize(value) @ inv(self.xform_matrix)
240            else:
241                value = homogenize([value]) @ inv(self.xform_matrix)
242            self.primary_points[subscript.start : subscript.stop : subscript.step] = [
243                tuple(x[:2]) for x in value
244            ]
245        elif isinstance(subscript, int):
246            value = homogenize([value]) @ inv(self.xform_matrix)
247            self.primary_points[subscript] = tuple(value[0][:2])
248        else:
249            raise TypeError("Invalid subscript type")
250
251    def __delitem__(self, subscript) -> Self:
252        """Delete the point(s) at the given subscript.
253
254        Args:
255            subscript (int or slice): The subscript to delete the point(s) from.
256        """
257        del self.primary_points[subscript]
258
259    def index(self, point: Point, atol=None) -> int:
260        """Return the index of the given point.
261
262        Args:
263            point (Point): The point to find the index of.
264
265        Returns:
266            int: The index of the point.
267        """
268        point = tuple(point[:2])
269
270        if atol is None:
271            atol = defaults["atol"]
272        ind = np.where((np.isclose(self.vertices, point, atol=atol)).all(axis=1))[0][0]
273
274        return ind
275
276    def remove(self, point: Point) -> Self:
277        """Remove a point from the shape.
278
279        Args:
280            point (Point): The point to remove.
281        """
282        ind = self.vertices.index(point)
283        self.primary_points.pop(ind)
284
285        return self
286
287    def append(self, point: Point) -> Self:
288        """Append a point to the shape.
289
290        Args:
291            point (Point): The point to append.
292        """
293        point = homogenize([point]) @ inv(self.xform_matrix)
294        self.primary_points.append(tuple(point[0][:2]))
295
296    def insert(self, index: int, point: Point) -> Self:
297        """Insert a point at a given index.
298
299        Args:
300            index (int): The index to insert the point at.
301            point (Point): The point to insert.
302        """
303        point = homogenize([point]) @ inv(self.xform_matrix)
304        self.primary_points.insert(index, tuple(point[0][:2]))
305
306        return self
307
308    def extend(self, points: Sequence[Point]) -> Self:
309        """Extend the shape with a list of points.
310
311        Args:
312            values (list[Point]): The points to extend the shape with.
313        """
314        homogenized = homogenize(points) @ inv(self.xform_matrix)
315        self.primary_points.extend([tuple(x[:2]) for x in homogenized])
316
317        return self
318
319    def pop(self, index: int = -1) -> Point:
320        """Pop a point from the shape.
321
322        Args:
323            index (int, optional): The index to pop the point from, defaults to -1.
324
325        Returns:
326            Point: The popped point.
327        """
328        point = self.vertices[index]
329        self.primary_points.pop(index)
330
331        return point
332
333    def __iter__(self):
334        """Return an iterator over the vertices of the shape.
335
336        Returns:
337            Iterator[Point]: An iterator over the vertices of the shape.
338        """
339        return iter(self.vertices)
340
341    def _update(self, xform_matrix: array, reps: int = 0) -> Batch:
342        """Used internally. Update the shape with a transformation matrix.
343
344        Args:
345            xform_matrix (array): The transformation matrix.
346            reps (int, optional): The number of repetitions, defaults to 0.
347
348        Returns:
349            Batch: The updated shape or a batch of shapes.
350        """
351        if reps == 0:
352            fillet_radius = self.fillet_radius
353            if fillet_radius:
354                scale = max(decompose_transformations(xform_matrix)[2])
355                self.fillet_radius = fillet_radius * scale
356            self.xform_matrix = self.xform_matrix @ xform_matrix
357            res = self
358        else:
359            shapes = [self]
360            shape = self
361            for _ in range(reps):
362                shape = shape.copy()
363                shape._update(xform_matrix)
364                shapes.append(shape)
365            res = Batch(shapes)
366        return res
367
368    def __eq__(self, other):
369        """Check if the shape is equal to another shape.
370
371        Args:
372            other (Shape): The other shape to compare to.
373
374        Returns:
375            bool: True if the shapes are equal, False otherwise.
376        """
377        if not hasattr(other, "type"):
378            return False
379        if other.type != Types.SHAPE:
380            return False
381
382        len1 = len(self)
383        len2 = len(other)
384        if len1 == 0 and len2 == 0:
385            res = True
386        elif len1 == 0 or len2 == 0:
387            res = False
388        elif isinstance(other, Shape) and len1 == len2:
389            res = allclose(
390                self.xform_matrix,
391                other.xform_matrix,
392                rtol=defaults["rtol"],
393                atol=defaults["atol"],
394            ) and allclose(self.primary_points.nd_array, other.primary_points.nd_array)
395        else:
396            res = False
397
398        return res
399
400    def __bool__(self):
401        """Return whether the shape has any points.
402
403        Returns:
404            bool: True if the shape has points, False otherwise.
405        """
406        return len(self.primary_points) > 0
407
408    def topology(self) -> Topology:
409        """Return info about the topology of the shape.
410
411        Returns:
412            set: A set of topology values.
413        """
414        t_map = {
415            "WITHIN": Topology.FOLDED,
416            "CONTAINS": Topology.FOLDED,
417            "COLL_CHAIN": Topology.COLLINEAR,
418            "YJOINT": Topology.YJOINT,
419            "CHAIN": Topology.SIMPLE,
420            "CONGRUENT": Topology.CONGRUENT,
421            "INTERSECT": Topology.INTERSECTING,
422        }
423        intersections = all_intersections(self.vertex_pairs, use_intersection3=True)
424        connections = []
425        for val in intersections.values():
426            connections.extend([x[0].value for x in val])
427        connections = set(connections)
428        topology = set((t_map[x] for x in connections))
429
430        if len(topology) > 1 and Topology.SIMPLE in topology:
431            topology.discard(Topology.SIMPLE)
432
433        return topology
434
435    def merge(self, other, dist_tol: float = None) -> Union[Self, None]:
436        """Merge two shapes if they are connected. Does not work for polygons.
437        Only polyline shapes can be merged together.
438
439        Args:
440            other (Shape): The other shape to merge with.
441            dist_tol (float, optional): The distance tolerance for merging, defaults to None.
442
443        Returns:
444            Shape or None: The merged shape or None if the shapes cannot be merged.
445        """
446        if dist_tol is None:
447            dist_tol = defaults["dist_tol"]
448
449        if self.closed or other.closed or self.is_polygon or other.is_polygon:
450            res = None
451        else:
452            vertices = self._chain_vertices(
453                self.as_list(), other.as_list(), dist_tol=dist_tol
454            )
455            if vertices:
456                closed = close_points2(vertices[0], vertices[-1], dist2=self.dist_tol2)
457                res = Shape(vertices, closed=closed)
458            else:
459                res = None
460
461        return res
462
463    def connect(self, other) -> Self:
464        """Connect two shapes by adding the other shape's vertices to self.
465
466        Args:
467            other (Shape): The other shape to connect.
468        """
469        self.extend(other.vertices)
470
471        return self
472
473    def _chain_vertices(
474        self, verts1: Sequence[Point], verts2: Sequence[Point], dist_tol: float = None
475    ) -> Union[List[Point], None]:
476        """Chain two sets of vertices if they are connected.
477
478        Args:
479            verts1 (list[Point]): The first set of vertices.
480            verts2 (list[Point]): The second set of vertices.
481            dist_tol (float, optional): The distance tolerance for chaining, defaults to None.
482
483        Returns:
484            list[Point] or None: The chained vertices or None if the vertices cannot be chained.
485        """
486        dist_tol2 = dist_tol * dist_tol
487        start1, end1 = verts1[0], verts1[-1]
488        start2, end2 = verts2[0], verts2[-1]
489        same_starts = close_points2(start1, start2, dist2=dist_tol2)
490        same_ends = close_points2(end1, end2, dist2=self.dist_tol2)
491        if same_starts and same_ends:
492            res = verts1
493        elif close_points2(end1, start2, dist2=self.dist_tol2):
494            verts2.pop(0)
495        elif close_points2(start1, end2, dist2=self.dist_tol2):
496            verts2.reverse()
497            verts1.reverse()
498            verts2.pop(0)
499        elif same_starts:
500            verts2.reverse()
501            verts2.pop(-1)
502            start = verts2[:]
503            end = verts1[:]
504            verts1 = start
505            verts2 = end
506        elif same_ends:
507            verts2.reverse()
508            verts2.pop(0)
509        else:
510            return None
511        if same_starts and same_ends:
512            all_verts = verts1 + verts2
513            if not right_handed(all_verts):
514                all_verts.reverse()
515            res = all_verts
516        else:
517            res = verts1 + verts2
518
519        return res
520
521    def _is_polygon(self, vertices: Sequence[Point]) -> bool:
522        """Return True if the vertices form a polygon.
523
524        Args:
525            vertices (list[Point]): The vertices to check.
526
527        Returns:
528            bool: True if the vertices form a polygon, False otherwise.
529        """
530        return close_points2(vertices[0][:2], vertices[-1][:2], dist2=self.dist_tol2)
531
532    def as_graph(self, directed=False, weighted=False, n_round=None) -> nx.Graph:
533        """Return the shape as a graph object.
534
535        Args:
536            directed (bool, optional): Whether the graph is directed, defaults to False.
537            weighted (bool, optional): Whether the graph is weighted, defaults to False.
538            n_round (int, optional): The number of decimal places to round to, defaults to None.
539
540        Returns:
541            Graph: The graph object.
542        """
543        if n_round is None:
544            n_round = defaults["n_round"]
545        vertices = [(round(v[0], n_round), round(v[1], n_round)) for v in self.vertices]
546        points = [Node(*n) for n in vertices]
547        pairs = connected_pairs(points)
548        edges = [GraphEdge(p[0], p[1]) for p in pairs]
549        if self.closed:
550            edges.append(GraphEdge(points[-1], points[0]))
551
552        if directed:
553            nx_graph = nx.DiGraph()
554            graph_type = Types.DIRECTED
555        else:
556            nx_graph = nx.Graph()
557            graph_type = Types.UNDIRECTED
558
559        for point in points:
560            nx_graph.add_node(point.id, point=point)
561
562        if weighted:
563            for edge in edges:
564                nx_graph.add_edge(edge.start.id, edge.end.id, weight=edge.length)
565            subtype = Types.WEIGHTED
566        else:
567            id_pairs = [(e.start.id, e.end.id) for e in edges]
568            nx_graph.add_edges_from(id_pairs)
569            subtype = Types.NONE
570        pairs = [(e.start.id, e.end.id) for e in edges]
571        try:
572            cycles = nx.cycle_basis(nx_graph)
573        except nx.exception.NetworkXNoCycle:
574            cycles = None
575
576        if cycles:
577            n = len(cycles)
578            for cycle in cycles:
579                cycle.append(cycle[0])
580            if n == 1:
581                cycle = cycles[0]
582        else:
583            cycles = None
584        graph = Graph(type=graph_type, subtype=subtype, nx_graph=nx_graph)
585        return graph
586
587    def as_array(self, homogeneous=False) -> np.ndarray:
588        """Return the vertices as an array.
589
590        Args:
591            homogeneous (bool, optional): Whether to return homogeneous coordinates, defaults to False.
592
593        Returns:
594            ndarray: The vertices as an array.
595        """
596        if homogeneous:
597            res = self.primary_points.nd_array @ self.xform_matrix
598        else:
599            res = array(self.vertices)
600        return res
601
602    def as_list(self) -> List[Point]:
603        """Return the vertices as a list of tuples.
604
605        Returns:
606            list[tuple]: The vertices as a list of tuples.
607        """
608        return list(self.vertices)
609
610    @property
611    def final_coords(self) -> np.ndarray:
612        """The final coordinates of the shape. primary_points @ xform_matrix.
613
614        Returns:
615            ndarray: The final coordinates of the shape.
616        """
617        if self.primary_points:
618            res = self.primary_points.homogen_coords @ self.xform_matrix
619        else:
620            res = []
621
622        return res
623
624    @property
625    def vertices(self) -> Tuple[Point]:
626        """The final coordinates of the shape.
627
628        Returns:
629            tuple: The final coordinates of the shape.
630        """
631        if self.primary_points:
632            res = tuple(((x[0], x[1]) for x in (self.final_coords[:, :2])))
633        else:
634            res = []
635
636        return res
637
638    @property
639    def vertex_pairs(self) -> List[Tuple[Point, Point]]:
640        """Return a list of connected pairs of vertices.
641
642        Returns:
643            list[tuple[Point, Point]]: A list of connected pairs of vertices.
644        """
645        vertices = list(self.vertices)
646        if self.closed:
647            vertices.append(vertices[0])
648        return connected_pairs(vertices)
649
650    @property
651    def orig_coords(self) -> np.ndarray:
652        """The primary points in homogeneous coordinates.
653
654        Returns:
655            ndarray: The primary points in homogeneous coordinates.
656        """
657        return self.primary_points.homogen_coords
658
659    @property
660    def b_box(self) -> BoundingBox:
661        """Return the bounding box of the shape.
662
663        Returns:
664            BoundingBox: The bounding box of the shape.
665        """
666        if self.primary_points:
667            self._b_box = bounding_box(self.final_coords)
668        else:
669            self._b_box = bounding_box([(0, 0)])
670        return self._b_box
671
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
688
689    @property
690    def total_length(self) -> float:
691        """Return the total length of the shape.
692
693        Returns:
694            float: The total length of the shape.
695        """
696        return polyline_length(self.vertices[:-1], self.closed)
697
698    @property
699    def is_polygon(self) -> bool:
700        """Return True if 'closed'.
701
702        Returns:
703            bool: True if the shape is closed, False otherwise.
704        """
705        return self.closed
706
707    def clear(self) -> Self:
708        """Clear all points and reset the style attributes.
709
710        Returns:
711            None
712        """
713        self.primary_points = Points()
714        self.xform_matrix = identity_matrix()
715        self.style = ShapeStyle()
716        self._set_aliases()
717        self._b_box = None
718
719        return self
720
721    def count(self, point: Point) -> int:
722        """Return the number of times the point is found in the shape.
723
724        Args:
725            point (Point): The point to count.
726
727        Returns:
728            int: The number of times the point is found in the shape.
729        """
730        verts = self.orig_coords @ self.xform_matrix
731        verts = verts[:, :2]
732        n = verts.shape[0]
733        point = array(point[:2])
734        values = np.tile(point, (n, 1))
735        col1 = (verts[:, 0] - values[:, 0]) ** 2
736        col2 = (verts[:, 1] - values[:, 1]) ** 2
737        distances = col1 + col2
738
739        return np.count_nonzero(distances <= self.dist_tol2)
740
741    def copy(self) -> "Shape":
742        """Return a copy of the shape.
743
744        Returns:
745            Shape: A copy of the shape.
746        """
747        if self.primary_points.coords:
748            points = self.primary_points.copy()
749        else:
750            points = []
751        shape = Shape(
752            points,
753            xform_matrix=self.xform_matrix,
754            closed=self.closed,
755            marker_type=self.marker_type,
756        )
757        for attrib in shape_style_map:
758            setattr(shape, attrib, getattr(self, attrib))
759        shape.subtype = self.subtype
760        custom_attribs = custom_attributes(self)
761        for attrib in custom_attribs:
762            setattr(shape, attrib, getattr(self, attrib))
763
764        return shape
765
766    @property
767    def edges(self) -> List[Line]:
768        """Return a list of edges.
769
770        Edges are represented as tuples of points:
771        edge: ((x1, y1), (x2, y2))
772        edges: [((x1, y1), (x2, y2)), ((x2, y2), (x3, y3)), ...]
773
774        Returns:
775            list[tuple[Point, Point]]: A list of edges.
776        """
777        vertices = list(self.vertices[:])
778        if self.closed:
779            vertices.append(vertices[0])
780
781        return connected_pairs(vertices)
782
783    @property
784    def segments(self) -> List[Line]:
785        """Return a list of edges.
786
787        Edges are represented as tuples of points:
788        edge: ((x1, y1), (x2, y2))
789        edges: [((x1, y1), (x2, y2)), ((x2, y2), (x3, y3)), ...]
790
791        Returns:
792            list[tuple[Point, Point]]: A list of edges.
793        """
794
795        return self.edges
796
797    def reverse(self) -> Self:
798        """Reverse the order of the vertices.
799
800        Returns:
801            None
802        """
803        self.primary_points.reverse()
804
805        return self

The main class for all geometric entities in Simetri.

A Shape is created by providing a sequence of points (a sequence of (x, y) coordinates). If a style argument (a ShapeStyle object) is provided, then its style attributes override the default values the Shape object would assign. Additional attributes (e.g. line_width, fill_color, line_style) may be provided.

Shape( points: Sequence[Sequence[float]] = None, closed: bool = False, xform_matrix: <built-in function array> = None, **kwargs)
 58    def __init__(
 59        self,
 60        points: Sequence[Point] = None,
 61        closed: bool = False,
 62        xform_matrix: np.array = None,
 63        **kwargs,
 64    ) -> None:
 65        """Initialize a Shape object.
 66
 67        Args:
 68            points (Sequence[Point], optional): The points that make up the shape.
 69            closed (bool, optional): Whether the shape is closed. Defaults to False.
 70            xform_matrix (np.array, optional): The transformation matrix. Defaults to None.
 71            **kwargs (dict): Additional attributes for the shape.
 72
 73        Raises:
 74            ValueError: If the provided subtype is not valid.
 75        """
 76
 77        if check_consecutive_duplicates(points):
 78            err_msg = "Consecutive duplicate points are not allowed in Shape objects."
 79            if not defaults["allow_consec_dup_points"]:
 80                err_msg += ' Set sg.defaults["allow_consec_dup_points"] to True if you really, really have to avoid this error.'
 81                raise ValueError(err_msg)
 82            warnings.warn(err_msg, UserWarning)
 83
 84        self.__dict__["style"] = ShapeStyle()
 85        self.__dict__["_style_map"] = shape_style_map
 86        self._set_aliases()
 87        valid_args = shape_args
 88        validate_args(kwargs, valid_args)
 89        if "subtype" in kwargs:
 90            if kwargs["subtype"] not in shape_types:
 91                raise ValueError(f"Invalid subtype: {kwargs['subtype']}")
 92            self.subtype = kwargs["subtype"]
 93            kwargs.pop("subtype")
 94        else:
 95            self.subtype = Types.SHAPE
 96
 97        if "dist_tol" in kwargs:
 98            self.dist_tol = kwargs["dist_tol"]
 99            self.dist_tol2 = self.dist_tol**2
100            kwargs.pop("dist_tol")
101        else:
102            self.dist_tol = defaults["dist_tol"]
103            self.dist_tol2 = self.dist_tol**2
104
105        if points is None:
106            self.primary_points = Points()
107            self.closed = False
108        else:
109            self.closed, points = self._get_closed(points, closed)
110            self.primary_points = Points(points)
111        self.xform_matrix = get_transform(xform_matrix)
112        self.type = Types.SHAPE
113        for key, value in kwargs.items():
114            setattr(self, key, value)
115
116        self._b_box = None
117        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. Common kwargs are, style, line_width, fill, fill_color, line_color, fill_alpha, etc.
Raises:
  • ValueError: If the provided subtype is not valid.
xform_matrix: "'ndarray'"
def index(self, point: Sequence[float], atol=None) -> int:
259    def index(self, point: Point, atol=None) -> int:
260        """Return the index of the given point.
261
262        Args:
263            point (Point): The point to find the index of.
264
265        Returns:
266            int: The index of the point.
267        """
268        point = tuple(point[:2])
269
270        if atol is None:
271            atol = defaults["atol"]
272        ind = np.where((np.isclose(self.vertices, point, atol=atol)).all(axis=1))[0][0]
273
274        return ind

Return the index of the given point.

Arguments:
  • point (Point): The point to find the index of.
Returns:

int: The index of the point.

def remove(self, point: Sequence[float]) -> typing_extensions.Self:
276    def remove(self, point: Point) -> Self:
277        """Remove a point from the shape.
278
279        Args:
280            point (Point): The point to remove.
281        """
282        ind = self.vertices.index(point)
283        self.primary_points.pop(ind)
284
285        return self

Remove a point from the shape.

Arguments:
  • point (Point): The point to remove.
def append(self, point: Sequence[float]) -> typing_extensions.Self:
287    def append(self, point: Point) -> Self:
288        """Append a point to the shape.
289
290        Args:
291            point (Point): The point to append.
292        """
293        point = homogenize([point]) @ inv(self.xform_matrix)
294        self.primary_points.append(tuple(point[0][:2]))

Append a point to the shape.

Arguments:
  • point (Point): The point to append.
def insert(self, index: int, point: Sequence[float]) -> typing_extensions.Self:
296    def insert(self, index: int, point: Point) -> Self:
297        """Insert a point at a given index.
298
299        Args:
300            index (int): The index to insert the point at.
301            point (Point): The point to insert.
302        """
303        point = homogenize([point]) @ inv(self.xform_matrix)
304        self.primary_points.insert(index, tuple(point[0][:2]))
305
306        return self

Insert a point at a given index.

Arguments:
  • index (int): The index to insert the point at.
  • point (Point): The point to insert.
def extend(self, points: Sequence[Sequence[float]]) -> typing_extensions.Self:
308    def extend(self, points: Sequence[Point]) -> Self:
309        """Extend the shape with a list of points.
310
311        Args:
312            values (list[Point]): The points to extend the shape with.
313        """
314        homogenized = homogenize(points) @ inv(self.xform_matrix)
315        self.primary_points.extend([tuple(x[:2]) for x in homogenized])
316
317        return self

Extend the shape with a list of points.

Arguments:
  • points (list[Point]): The points to extend the shape with.
def pop(self, index: int = -1) -> Sequence[float]:
319    def pop(self, index: int = -1) -> Point:
320        """Pop a point from the shape.
321
322        Args:
323            index (int, optional): The index to pop the point from, defaults to -1.
324
325        Returns:
326            Point: The popped point.
327        """
328        point = self.vertices[index]
329        self.primary_points.pop(index)
330
331        return point

Pop a point from the shape.

Arguments:
  • index (int, optional): The index to pop the point from, defaults to -1.
Returns:

Point: The popped point.

def topology(self) -> simetri.graphics.all_enums.Topology:
408    def topology(self) -> Topology:
409        """Return info about the topology of the shape.
410
411        Returns:
412            set: A set of topology values.
413        """
414        t_map = {
415            "WITHIN": Topology.FOLDED,
416            "CONTAINS": Topology.FOLDED,
417            "COLL_CHAIN": Topology.COLLINEAR,
418            "YJOINT": Topology.YJOINT,
419            "CHAIN": Topology.SIMPLE,
420            "CONGRUENT": Topology.CONGRUENT,
421            "INTERSECT": Topology.INTERSECTING,
422        }
423        intersections = all_intersections(self.vertex_pairs, use_intersection3=True)
424        connections = []
425        for val in intersections.values():
426            connections.extend([x[0].value for x in val])
427        connections = set(connections)
428        topology = set((t_map[x] for x in connections))
429
430        if len(topology) > 1 and Topology.SIMPLE in topology:
431            topology.discard(Topology.SIMPLE)
432
433        return topology

Return info about the topology of the shape.

Returns:

set: A set of topology values.

def merge(self, other, dist_tol: float = None) -> Optional[typing_extensions.Self]:
435    def merge(self, other, dist_tol: float = None) -> Union[Self, None]:
436        """Merge two shapes if they are connected. Does not work for polygons.
437        Only polyline shapes can be merged together.
438
439        Args:
440            other (Shape): The other shape to merge with.
441            dist_tol (float, optional): The distance tolerance for merging, defaults to None.
442
443        Returns:
444            Shape or None: The merged shape or None if the shapes cannot be merged.
445        """
446        if dist_tol is None:
447            dist_tol = defaults["dist_tol"]
448
449        if self.closed or other.closed or self.is_polygon or other.is_polygon:
450            res = None
451        else:
452            vertices = self._chain_vertices(
453                self.as_list(), other.as_list(), dist_tol=dist_tol
454            )
455            if vertices:
456                closed = close_points2(vertices[0], vertices[-1], dist2=self.dist_tol2)
457                res = Shape(vertices, closed=closed)
458            else:
459                res = None
460
461        return res

Merge two shapes if they are connected. Does not work for polygons. Only polyline shapes can be merged together.

Arguments:
  • other (Shape): The other shape to merge with.
  • dist_tol (float, optional): The distance tolerance for merging, defaults to None.
Returns:

Shape or None: The merged shape or None if the shapes cannot be merged.

def connect(self, other) -> typing_extensions.Self:
463    def connect(self, other) -> Self:
464        """Connect two shapes by adding the other shape's vertices to self.
465
466        Args:
467            other (Shape): The other shape to connect.
468        """
469        self.extend(other.vertices)
470
471        return self

Connect two shapes by adding the other shape's vertices to self.

Arguments:
  • other (Shape): The other shape to connect.
def as_graph(self, directed=False, weighted=False, n_round=None) -> 'nx.Graph':
532    def as_graph(self, directed=False, weighted=False, n_round=None) -> nx.Graph:
533        """Return the shape as a graph object.
534
535        Args:
536            directed (bool, optional): Whether the graph is directed, defaults to False.
537            weighted (bool, optional): Whether the graph is weighted, defaults to False.
538            n_round (int, optional): The number of decimal places to round to, defaults to None.
539
540        Returns:
541            Graph: The graph object.
542        """
543        if n_round is None:
544            n_round = defaults["n_round"]
545        vertices = [(round(v[0], n_round), round(v[1], n_round)) for v in self.vertices]
546        points = [Node(*n) for n in vertices]
547        pairs = connected_pairs(points)
548        edges = [GraphEdge(p[0], p[1]) for p in pairs]
549        if self.closed:
550            edges.append(GraphEdge(points[-1], points[0]))
551
552        if directed:
553            nx_graph = nx.DiGraph()
554            graph_type = Types.DIRECTED
555        else:
556            nx_graph = nx.Graph()
557            graph_type = Types.UNDIRECTED
558
559        for point in points:
560            nx_graph.add_node(point.id, point=point)
561
562        if weighted:
563            for edge in edges:
564                nx_graph.add_edge(edge.start.id, edge.end.id, weight=edge.length)
565            subtype = Types.WEIGHTED
566        else:
567            id_pairs = [(e.start.id, e.end.id) for e in edges]
568            nx_graph.add_edges_from(id_pairs)
569            subtype = Types.NONE
570        pairs = [(e.start.id, e.end.id) for e in edges]
571        try:
572            cycles = nx.cycle_basis(nx_graph)
573        except nx.exception.NetworkXNoCycle:
574            cycles = None
575
576        if cycles:
577            n = len(cycles)
578            for cycle in cycles:
579                cycle.append(cycle[0])
580            if n == 1:
581                cycle = cycles[0]
582        else:
583            cycles = None
584        graph = Graph(type=graph_type, subtype=subtype, nx_graph=nx_graph)
585        return graph

Return the shape as a graph object.

Arguments:
  • directed (bool, optional): Whether the graph is directed, defaults to False.
  • weighted (bool, optional): Whether the graph is weighted, defaults to False.
  • n_round (int, optional): The number of decimal places to round to, defaults to None.
Returns:

Graph: The graph object.

def as_array(self, homogeneous=False) -> 'ndarray':
587    def as_array(self, homogeneous=False) -> np.ndarray:
588        """Return the vertices as an array.
589
590        Args:
591            homogeneous (bool, optional): Whether to return homogeneous coordinates, defaults to False.
592
593        Returns:
594            ndarray: The vertices as an array.
595        """
596        if homogeneous:
597            res = self.primary_points.nd_array @ self.xform_matrix
598        else:
599            res = array(self.vertices)
600        return res

Return the vertices as an array.

Arguments:
  • homogeneous (bool, optional): Whether to return homogeneous coordinates, defaults to False.
Returns:

ndarray: The vertices as an array.

def as_list(self) -> List[Sequence[float]]:
602    def as_list(self) -> List[Point]:
603        """Return the vertices as a list of tuples.
604
605        Returns:
606            list[tuple]: The vertices as a list of tuples.
607        """
608        return list(self.vertices)

Return the vertices as a list of tuples.

Returns:

list[tuple]: The vertices as a list of tuples.

final_coords: 'ndarray'
610    @property
611    def final_coords(self) -> np.ndarray:
612        """The final coordinates of the shape. primary_points @ xform_matrix.
613
614        Returns:
615            ndarray: The final coordinates of the shape.
616        """
617        if self.primary_points:
618            res = self.primary_points.homogen_coords @ self.xform_matrix
619        else:
620            res = []
621
622        return res

The final coordinates of the shape. primary_points @ xform_matrix.

Returns:

ndarray: The final coordinates of the shape.

vertices: Tuple[Sequence[float]]
624    @property
625    def vertices(self) -> Tuple[Point]:
626        """The final coordinates of the shape.
627
628        Returns:
629            tuple: The final coordinates of the shape.
630        """
631        if self.primary_points:
632            res = tuple(((x[0], x[1]) for x in (self.final_coords[:, :2])))
633        else:
634            res = []
635
636        return res

The final coordinates of the shape.

Returns:

tuple: The final coordinates of the shape.

vertex_pairs: List[Tuple[Sequence[float], Sequence[float]]]
638    @property
639    def vertex_pairs(self) -> List[Tuple[Point, Point]]:
640        """Return a list of connected pairs of vertices.
641
642        Returns:
643            list[tuple[Point, Point]]: A list of connected pairs of vertices.
644        """
645        vertices = list(self.vertices)
646        if self.closed:
647            vertices.append(vertices[0])
648        return connected_pairs(vertices)

Return a list of connected pairs of vertices.

Returns:

list[tuple[Point, Point]]: A list of connected pairs of vertices.

orig_coords: 'ndarray'
650    @property
651    def orig_coords(self) -> np.ndarray:
652        """The primary points in homogeneous coordinates.
653
654        Returns:
655            ndarray: The primary points in homogeneous coordinates.
656        """
657        return self.primary_points.homogen_coords

The primary points in homogeneous coordinates.

Returns:

ndarray: The primary points in homogeneous coordinates.

659    @property
660    def b_box(self) -> BoundingBox:
661        """Return the bounding box of the shape.
662
663        Returns:
664            BoundingBox: The bounding box of the shape.
665        """
666        if self.primary_points:
667            self._b_box = bounding_box(self.final_coords)
668        else:
669            self._b_box = bounding_box([(0, 0)])
670        return self._b_box

Return the bounding box of the shape.

Returns:

BoundingBox: The bounding box of the shape.

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.

total_length: float
689    @property
690    def total_length(self) -> float:
691        """Return the total length of the shape.
692
693        Returns:
694            float: The total length of the shape.
695        """
696        return polyline_length(self.vertices[:-1], self.closed)

Return the total length of the shape.

Returns:

float: The total length of the shape.

is_polygon: bool
698    @property
699    def is_polygon(self) -> bool:
700        """Return True if 'closed'.
701
702        Returns:
703            bool: True if the shape is closed, False otherwise.
704        """
705        return self.closed

Return True if 'closed'.

Returns:

bool: True if the shape is closed, False otherwise.

def clear(self) -> typing_extensions.Self:
707    def clear(self) -> Self:
708        """Clear all points and reset the style attributes.
709
710        Returns:
711            None
712        """
713        self.primary_points = Points()
714        self.xform_matrix = identity_matrix()
715        self.style = ShapeStyle()
716        self._set_aliases()
717        self._b_box = None
718
719        return self

Clear all points and reset the style attributes.

Returns:

None

def count(self, point: Sequence[float]) -> int:
721    def count(self, point: Point) -> int:
722        """Return the number of times the point is found in the shape.
723
724        Args:
725            point (Point): The point to count.
726
727        Returns:
728            int: The number of times the point is found in the shape.
729        """
730        verts = self.orig_coords @ self.xform_matrix
731        verts = verts[:, :2]
732        n = verts.shape[0]
733        point = array(point[:2])
734        values = np.tile(point, (n, 1))
735        col1 = (verts[:, 0] - values[:, 0]) ** 2
736        col2 = (verts[:, 1] - values[:, 1]) ** 2
737        distances = col1 + col2
738
739        return np.count_nonzero(distances <= self.dist_tol2)

Return the number of times the point is found in the shape.

Arguments:
  • point (Point): The point to count.
Returns:

int: The number of times the point is found in the shape.

def copy(self) -> Shape:
741    def copy(self) -> "Shape":
742        """Return a copy of the shape.
743
744        Returns:
745            Shape: A copy of the shape.
746        """
747        if self.primary_points.coords:
748            points = self.primary_points.copy()
749        else:
750            points = []
751        shape = Shape(
752            points,
753            xform_matrix=self.xform_matrix,
754            closed=self.closed,
755            marker_type=self.marker_type,
756        )
757        for attrib in shape_style_map:
758            setattr(shape, attrib, getattr(self, attrib))
759        shape.subtype = self.subtype
760        custom_attribs = custom_attributes(self)
761        for attrib in custom_attribs:
762            setattr(shape, attrib, getattr(self, attrib))
763
764        return shape

Return a copy of the shape.

Returns:

Shape: A copy of the shape.

edges: List[Sequence[Sequence]]
766    @property
767    def edges(self) -> List[Line]:
768        """Return a list of edges.
769
770        Edges are represented as tuples of points:
771        edge: ((x1, y1), (x2, y2))
772        edges: [((x1, y1), (x2, y2)), ((x2, y2), (x3, y3)), ...]
773
774        Returns:
775            list[tuple[Point, Point]]: A list of edges.
776        """
777        vertices = list(self.vertices[:])
778        if self.closed:
779            vertices.append(vertices[0])
780
781        return connected_pairs(vertices)

Return a list of edges.

Edges are represented as tuples of points: edge: ((x1, y1), (x2, y2)) edges: [((x1, y1), (x2, y2)), ((x2, y2), (x3, y3)), ...]

Returns:

list[tuple[Point, Point]]: A list of edges.

segments: List[Sequence[Sequence]]
783    @property
784    def segments(self) -> List[Line]:
785        """Return a list of edges.
786
787        Edges are represented as tuples of points:
788        edge: ((x1, y1), (x2, y2))
789        edges: [((x1, y1), (x2, y2)), ((x2, y2), (x3, y3)), ...]
790
791        Returns:
792            list[tuple[Point, Point]]: A list of edges.
793        """
794
795        return self.edges

Return a list of edges.

Edges are represented as tuples of points: edge: ((x1, y1), (x2, y2)) edges: [((x1, y1), (x2, y2)), ((x2, y2), (x3, y3)), ...]

Returns:

list[tuple[Point, Point]]: A list of edges.

def reverse(self) -> typing_extensions.Self:
797    def reverse(self) -> Self:
798        """Reverse the order of the vertices.
799
800        Returns:
801            None
802        """
803        self.primary_points.reverse()
804
805        return self

Reverse the order of the vertices.

Returns:

None

def custom_attributes(item: Shape) -> List[str]:
808def custom_attributes(item: Shape) -> List[str]:
809    """Return a list of custom attributes of a Shape or Batch instance.
810
811    Args:
812        item (Shape): The Shape or Batch instance.
813
814    Returns:
815        list[str]: A list of custom attribute names.
816
817    Raises:
818        TypeError: If the item is not a Shape instance.
819    """
820    if isinstance(item, Shape):
821        dummy = Shape([(0, 0), (1, 0)])
822    else:
823        raise TypeError("Invalid item type")
824    native_attribs = set(dir(dummy))
825    custom_attribs = set(dir(item)) - native_attribs
826
827    return list(custom_attribs)

Return a list of custom attributes of a Shape or Batch instance.

Arguments:
  • item (Shape): The Shape or Batch instance.
Returns:

list[str]: A list of custom attribute names.

Raises:
  • TypeError: If the item is not a Shape instance.