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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.