Source code for simetri.graphics.shape

"""Shape objects are the main geometric entities in Simetri. They are created by providing a sequence of points (a list of (x, y) coordinates). If a style argument (a ShapeStyle object) is provided, then the style attributes of this ShapeStyle object will superseed the style attributes of the Shape object. The dist_tol argument is the distance tolerance for checking. The xform_matrix argument is the transformation matrix. Additional attributes can be provided as keyword arguments. The line_width, fill_color, line_style, etc. for style attributes can be provided as keyword arguments. The Shape object has a subtype attribute that can be set to one of the values in the shape_types dictionary. The Shape object has a dist_tol attribute that is the distance tolerance for checking. The Shape object has a dist_tol2 attribute that is the square of the distance tolerance. The Shape object has a primary_points attribute that is a Points object. The Shape object has a closed attribute that is a boolean value. The Shape object has an xform_matrix attribute that is a transformation matrix. The Shape object has a type attribute that is a Types.SHAPE object. The Shape object has a subtype attribute that is a Types.SHAPE object. The Shape object has a dist_tol attribute that is a distance tolerance for checking. The Shape object has a dist_tol2 attribute that is the square of the distance tolerance. The Shape object has a _b_box attribute that is a bounding box. The Shape object has a area attribute that is the area of the shape. The Shape object has a total_length attribute that is the total length of the shape. The Shape object has a is_polygon attribute that is a boolean value. The Shape object has a topology attribute that is a set of topology values. The Shape object has a merge method that merges two shapes if they are connected. The Shape object has a _chain_vertices method that chains two sets of vertices if they are connected. The Shape object has a _is_polygon method that returns True if the vertices form a polygon. The Shape object has an as_graph method that returns the shape as a graph object. The Shape object has an as_array method that returns the vertices as an array. The Shape object has an as_list method that returns the vertices as a list of tuples. The Shape object has a final_coords attribute that is the final coordinates of the shape. The Shape object has a vertices attribute that is the final coordinates of the shape. The Shape object has a vertex"""

__all__ = ["Shape", "custom_attributes"]

from typing import Sequence, Union, List

import numpy as np
from numpy import array, allclose
from numpy.linalg import inv
import networkx as nx
from typing_extensions import Self

from .affine import identity_matrix
from .all_enums import *
from ..canvas.style_map import ShapeStyle, shape_style_map
from ..helpers.validation import validate_args
from .common import Point, common_properties, get_item_by_id, Line
from ..canvas.style_map import shape_args
from ..settings.settings import defaults
from ..helpers.utilities import (
    get_transform,
    is_nested_sequence,
    decompose_transformations,
)
from ..geometry.geometry import (
    homogenize,
    right_handed,
    all_intersections,
    polygon_area,
    polyline_length,
    close_points2,
    connected_pairs,
)
from ..helpers.graph import Node, Graph, GraphEdge
from .core import Base
from .bbox import bounding_box
from .points import Points
from .batch import Batch


[docs] class Shape(Base): """The main class for all geometric entities in Simetri. A Shape is created by providing a sequence of points (a list of (x, y) coordinates). If a style argument (a ShapeStyle object) is provided, then its style attributes override those of the Shape object. Additional attributes (e.g. line_width, fill_color, line_style) may be provided. Attributes: dist_tol: Distance tolerance for checking. xform_matrix: Transformation matrix. """ def __init__( self, points: Sequence[Point] = None, closed: bool = False, xform_matrix: np.array = None, **kwargs, ) -> None: """Initialize a Shape object. Args: points (Sequence[Point], optional): The points that make up the shape. closed (bool, optional): Whether the shape is closed. Defaults to False. xform_matrix (np.array, optional): The transformation matrix. Defaults to None. **kwargs (dict): Additional attributes for the shape. Raises: ValueError: If the provided subtype is not valid. """ self.__dict__["style"] = ShapeStyle() self.__dict__["_style_map"] = shape_style_map self._set_aliases() valid_args = shape_args validate_args(kwargs, valid_args) if "subtype" in kwargs: if kwargs["subtype"] not in shape_types: raise ValueError(f"Invalid subtype: {kwargs['subtype']}") self.subtype = kwargs["subtype"] kwargs.pop("subtype") else: self.subtype = Types.SHAPE if "dist_tol" in kwargs: self.dist_tol = kwargs["dist_tol"] self.dist_tol2 = self.dist_tol**2 kwargs.pop("dist_tol") else: self.dist_tol = defaults["dist_tol"] self.dist_tol2 = self.dist_tol**2 if points is None: self.primary_points = Points() self.closed = False else: self.closed, points = self._get_closed(points, closed) self.primary_points = Points(points) self.xform_matrix = get_transform(xform_matrix) self.type = Types.SHAPE for key, value in kwargs.items(): setattr(self, key, value) self._b_box = None common_properties(self) def __setattr__(self, name, value): """Set an attribute of the shape. Args: name (str): The name of the attribute. value (Any): The value to set. """ obj, attrib = self.__dict__["_aliasses"].get(name, (None, None)) if obj: setattr(obj, attrib, value) else: self.__dict__[name] = value def __getattr__(self, name): """Retrieve an attribute of the shape. Args: name (str): The attribute name to return. Returns: Any: The value of the attribute. Raises: AttributeError: If the attribute cannot be found. """ obj, attrib = self.__dict__["_aliasses"].get(name, (None, None)) if obj: res = getattr(obj, attrib) else: try: res = super().__getattr__(name) except AttributeError: res = self.__dict__[name] return res def _set_aliases(self): """Set aliases for style attributes based on the style map.""" _aliasses = {} for alias, path_attrib in self._style_map.items(): style_path, attrib = path_attrib obj = self for attrib_name in style_path.split("."): obj = obj.__dict__[attrib_name] _aliasses[alias] = (obj, attrib) self.__dict__["_aliasses"] = _aliasses def _get_closed(self, points: Sequence[Point], closed: bool): """Determine whether the shape should be considered closed. Args: points (Sequence[Point]): The points that define the shape. closed (bool): The user-specified closed flag. Returns: tuple: A tuple consisting of: - bool: True if the shape is closed, False otherwise. - list: The (possibly modified) list of points. """ decision_table = { (True, True): True, (True, False): True, (False, True): True, (False, False): False, } n = len(points) if n < 3: res = False else: points = [tuple(x[:2]) for x in points] polygon = self._is_polygon(points) res = decision_table[(bool(closed), polygon)] if polygon: points.pop() return res, points def __len__(self): """Return the number of points in the shape. Returns: int: The number of primary points. """ return len(self.primary_points) def __str__(self): """Return a string representation of the shape. Returns: str: A string representation of the shape. """ if len(self.primary_points) == 0: res = "Shape()" elif len(self.primary_points) < 4: res = f"Shape({self.vertices})" else: res = f"Shape([{self.vertices[0]}, ..., {self.vertices[-1]}])" return res def __repr__(self): """Return a string representation of the shape. Returns: str: A string representation of the shape. """ return self.__str__() def __getitem__(self, subscript: Union[int, slice]): """Retrieve point(s) from the shape by index or slice. Args: subscript (int or slice): The index or slice specifying the point(s) to retrieve. Returns: Point or list[Point]: The requested point or list of points (after applying the transformation). Raises: TypeError: If the subscript type is invalid. """ if isinstance(subscript, slice): coords = self.primary_points.homogen_coords res = list(coords[subscript.start : subscript.stop : subscript.step] @ self.xform_matrix) else: res = self.primary_points.homogen_coords[subscript] @ self.xform_matrix return res def __setitem__(self, subscript, value): """Set the point(s) at the given subscript. Args: subscript (int or slice): The subscript to set the point(s) at. value (Point or list[Point]): The value to set the point(s) to. Raises: TypeError: If the subscript type is invalid. """ if isinstance(subscript, slice): if is_nested_sequence(value): value = homogenize(value) @ inv(self.xform_matrix) else: value = homogenize([value]) @ inv(self.xform_matrix) self.primary_points[subscript.start : subscript.stop : subscript.step] = [ tuple(x[:2]) for x in value ] elif isinstance(subscript, int): value = homogenize([value]) @ inv(self.xform_matrix) self.primary_points[subscript] = tuple(value[0][:2]) else: raise TypeError("Invalid subscript type") def __delitem__(self, subscript) -> Self: """Delete the point(s) at the given subscript. Args: subscript (int or slice): The subscript to delete the point(s) from. """ del self.primary_points[subscript]
[docs] def remove(self, value): """Remove a point from the shape. Args: value (Point): The point to remove. """ ind = self.vertices.index(value) self.primary_points.pop(ind)
[docs] def append(self, value): """Append a point to the shape. Args: value (Point): The point to append. """ value = homogenize([value]) @ inv(self.xform_matrix) self.primary_points.append(tuple(value[0][:2]))
[docs] def insert(self, index, value): """Insert a point at a given index. Args: index (int): The index to insert the point at. value (Point): The point to insert. """ value = homogenize([value]) @ inv(self.xform_matrix) self.primary_points.insert(index, tuple(value[0][:2]))
[docs] def extend(self, values): """Extend the shape with a list of points. Args: values (list[Point]): The points to extend the shape with. """ homogenized = homogenize(values) @ inv(self.xform_matrix) self.primary_points.extend([tuple(x[:2]) for x in homogenized])
[docs] def pop(self, index: int = -1): """Pop a point from the shape. Args: index (int, optional): The index to pop the point from, defaults to -1. Returns: Point: The popped point. """ value = self.vertices[index] self.primary_points.pop(index) return value
def __iter__(self): """Return an iterator over the vertices of the shape. Returns: Iterator[Point]: An iterator over the vertices of the shape. """ return iter(self.vertices) def _update(self, xform_matrix: array, reps: int = 0) -> Batch: """Used internally. Update the shape with a transformation matrix. Args: xform_matrix (array): The transformation matrix. reps (int, optional): The number of repetitions, defaults to 0. Returns: Batch: The updated shape or a batch of shapes. """ if reps == 0: fillet_radius = self.fillet_radius if fillet_radius: scale = max(decompose_transformations(xform_matrix)[2]) self.fillet_radius = fillet_radius * scale self.xform_matrix = self.xform_matrix @ xform_matrix res = self else: shapes = [self] shape = self for _ in range(reps): shape = shape.copy() shape._update(xform_matrix) shapes.append(shape) res = Batch(shapes) return res def __eq__(self, other): """Check if the shape is equal to another shape. Args: other (Shape): The other shape to compare to. Returns: bool: True if the shapes are equal, False otherwise. """ len1 = len(self) len2 = len(other) if len1 == 0 and len2 == 0: res = True elif len1 == 0 or len2 == 0: res = False elif isinstance(other, Shape) and len1 == len2: res = allclose( self.xform_matrix, other.xform_matrix, rtol=defaults["rtol"], atol=defaults["atol"], ) and allclose(self.primary_points.nd_array, other.primary_points.nd_array) else: res = False return res def __bool__(self): """Return whether the shape has any points. Returns: bool: True if the shape has points, False otherwise. """ return len(self.primary_points) > 0
[docs] def topology(self): """Return info about the topology of the shape. Returns: set: A set of topology values. """ t_map = { "WITHIN": Topology.FOLDED, "CONTAINS": Topology.FOLDED, "COLL_CHAIN": Topology.COLLINEAR, "YJOINT": Topology.YJOINT, "CHAIN": Topology.SIMPLE, "CONGRUENT": Topology.CONGRUENT, "INTERSECT": Topology.INTERSECTING, } intersections = all_intersections(self.vertex_pairs, use_intersection3=True) connections = [] for val in intersections.values(): connections.extend([x[0].value for x in val]) connections = set(connections) topology = set((t_map[x] for x in connections)) if len(topology) > 1 and Topology.SIMPLE in topology: topology.discard(Topology.SIMPLE) return topology
[docs] def merge(self, other, dist_tol: float = None): """Merge two shapes if they are connected. Does not work for polygons. Only polyline shapes can be merged together. Args: 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. """ if dist_tol is None: dist_tol = defaults["dist_tol"] if self.closed or other.closed or self.is_polygon or other.is_polygon: res = None else: vertices = self._chain_vertices( self.as_list(), other.as_list(), dist_tol=dist_tol ) if vertices: closed = close_points2(vertices[0], vertices[-1], dist2=self.dist_tol2) res = Shape(vertices, closed=closed) else: res = None return res
[docs] def connect(self, other): """Connect two shapes by adding the other shape's vertices to self. Args: other (Shape): The other shape to connect. """ self.extend(other.vertices)
def _chain_vertices(self, verts1, verts2, dist_tol: float = None): """Chain two sets of vertices if they are connected. Args: verts1 (list[Point]): The first set of vertices. verts2 (list[Point]): The second set of vertices. dist_tol (float, optional): The distance tolerance for chaining, defaults to None. Returns: list[Point] or None: The chained vertices or None if the vertices cannot be chained. """ dist_tol2 = dist_tol * dist_tol start1, end1 = verts1[0], verts1[-1] start2, end2 = verts2[0], verts2[-1] same_starts = close_points2(start1, start2, dist2=dist_tol2) same_ends = close_points2(end1, end2, dist2=self.dist_tol2) if same_starts and same_ends: res = verts1 elif close_points2(end1, start2, dist2=self.dist_tol2): verts2.pop(0) elif close_points2(start1, end2, dist2=self.dist_tol2): verts2.reverse() verts1.reverse() verts2.pop(0) elif same_starts: verts2.reverse() verts2.pop(-1) start = verts2[:] end = verts1[:] verts1 = start verts2 = end elif same_ends: verts2.reverse() verts2.pop(0) else: return None if same_starts and same_ends: all_verts = verts1 + verts2 if not right_handed(all_verts): all_verts.reverse() res = all_verts else: res = verts1 + verts2 return res def _is_polygon(self, vertices): """Return True if the vertices form a polygon. Args: vertices (list[Point]): The vertices to check. Returns: bool: True if the vertices form a polygon, False otherwise. """ return close_points2(vertices[0][:2], vertices[-1][:2], dist2=self.dist_tol2)
[docs] def as_graph(self, directed=False, weighted=False, n_round=None): """Return the shape as a graph object. Args: 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. """ if n_round is None: n_round = defaults["n_round"] vertices = [(round(v[0], n_round), round(v[1], n_round)) for v in self.vertices] points = [Node(*n) for n in vertices] pairs = connected_pairs(points) edges = [GraphEdge(p[0], p[1]) for p in pairs] if self.closed: edges.append(GraphEdge(points[-1], points[0])) if directed: nx_graph = nx.DiGraph() graph_type = Types.DIRECTED else: nx_graph = nx.Graph() graph_type = Types.UNDIRECTED for point in points: nx_graph.add_node(point.id, point=point) if weighted: for edge in edges: nx_graph.add_edge(edge.start.id, edge.end.id, weight=edge.length) subtype = Types.WEIGHTED else: id_pairs = [(e.start.id, e.end.id) for e in edges] nx_graph.add_edges_from(id_pairs) subtype = Types.NONE pairs = [(e.start.id, e.end.id) for e in edges] try: cycles = nx.cycle_basis(nx_graph) except nx.exception.NetworkXNoCycle: cycles = None if cycles: n = len(cycles) for cycle in cycles: cycle.append(cycle[0]) if n == 1: cycle = cycles[0] else: cycles = None graph = Graph(type=graph_type, subtype=subtype, nx_graph=nx_graph) return graph
[docs] def as_array(self, homogeneous=False): """Return the vertices as an array. Args: homogeneous (bool, optional): Whether to return homogeneous coordinates, defaults to False. Returns: ndarray: The vertices as an array. """ if homogeneous: res = self.primary_points.nd_array @ self.xform_matrix else: res = array(self.vertices) return res
[docs] def as_list(self): """Return the vertices as a list of tuples. Returns: list[tuple]: The vertices as a list of tuples. """ return list(self.vertices)
@property def final_coords(self): """The final coordinates of the shape. primary_points @ xform_matrix. Returns: ndarray: The final coordinates of the shape. """ if self.primary_points: res = self.primary_points.homogen_coords @ self.xform_matrix else: res = [] return res @property def vertices(self): """The final coordinates of the shape. Returns: tuple: The final coordinates of the shape. """ if self.primary_points: res = tuple(((x[0], x[1]) for x in (self.final_coords[:, :2]))) else: res = [] return res @property def vertex_pairs(self): """Return a list of connected pairs of vertices. Returns: list[tuple[Point, Point]]: A list of connected pairs of vertices. """ vertices = list(self.vertices) if self.closed: vertices.append(vertices[0]) return connected_pairs(vertices) @property def orig_coords(self): """The primary points in homogeneous coordinates. Returns: ndarray: The primary points in homogeneous coordinates. """ return self.primary_points.homogen_coords @property def b_box(self): """Return the bounding box of the shape. Returns: BoundingBox: The bounding box of the shape. """ if self.primary_points: self._b_box = bounding_box(self.final_coords) else: self._b_box = bounding_box([(0, 0)]) return self._b_box @property def area(self): """Return the area of the shape. Returns: float: The area of the shape. """ if self.closed: vertices = self.vertices[:] if not close_points2(vertices[0], vertices[-1], dist2=self.dist_tol2): vertices = list(vertices) + [vertices[0]] res = polygon_area(vertices) else: res = 0 return res @property def total_length(self): """Return the total length of the shape. Returns: float: The total length of the shape. """ return polyline_length(self.vertices[:-1], self.closed) @property def is_polygon(self): """Return True if 'closed'. Returns: bool: True if the shape is closed, False otherwise. """ return self.closed
[docs] def clear(self): """Clear all points and reset the style attributes. Returns: None """ self.primary_points = Points() self.xform_matrix = identity_matrix() self.style = ShapeStyle() self._set_aliases() self._b_box = None
[docs] def count(self, value): """Return the number of times the value is found in the shape. Args: value (Point): The value to count. Returns: int: The number of times the value is found in the shape. """ verts = self.orig_coords @ self.xform_matrix verts = verts[:, :2] n = verts.shape[0] value = array(value[:2]) values = np.tile(value, (n, 1)) col1 = (verts[:, 0] - values[:, 0]) ** 2 col2 = (verts[:, 1] - values[:, 1]) ** 2 distances = col1 + col2 return np.count_nonzero(distances <= self.dist_tol2)
[docs] def copy(self): """Return a copy of the shape. Returns: Shape: A copy of the shape. """ if self.primary_points.coords: points = self.primary_points.copy() else: points = [] shape = Shape( points, xform_matrix=self.xform_matrix, closed=self.closed, marker_type=self.marker_type, ) for attrib in shape_style_map: setattr(shape, attrib, getattr(self, attrib)) shape.subtype = self.subtype custom_attribs = custom_attributes(self) for attrib in custom_attribs: setattr(shape, attrib, getattr(self, attrib)) return shape
@property def edges(self) -> List[Line]: """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. """ vertices = list(self.vertices[:]) if self.closed: vertices.append(vertices[0]) return connected_pairs(vertices)
[docs] def reverse(self): """Reverse the order of the vertices. Returns: None """ self.primary_points.reverse()
[docs] def custom_attributes(item: Shape) -> List[str]: """Return a list of custom attributes of a Shape or Batch instance. Args: 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. """ if isinstance(item, Shape): dummy = Shape([(0, 0), (1, 0)]) else: raise TypeError("Invalid item type") native_attribs = set(dir(dummy)) custom_attribs = set(dir(item)) - native_attribs return list(custom_attribs)