""" entities.py -------------- Basic geometric primitives which only store references to vertex indices rather than vertices themselves. """ import numpy as np import copy from .arc import discretize_arc, arc_center from .curve import discretize_bezier, discretize_bspline from .. import util class Entity(object): def __init__(self, points, closed=None, layer=None, color=None, **kwargs): # points always reference vertex indices and are int self.points = np.asanyarray(points, dtype=np.int64) # save explicit closed if closed is not None: self.closed = closed # save the passed layer self.layer = layer # save the passed color self.color = color # save any other kwargs for general use self.kwargs = kwargs def to_dict(self): """ Returns a dictionary with all of the information about the entity. Returns ----------- as_dict : dict Has keys 'type', 'points', 'closed' """ return {'type': self.__class__.__name__, 'points': self.points.tolist(), 'closed': self.closed} @property def closed(self): """ If the first point is the same as the end point the entity is closed Returns ----------- closed : bool Is the entity closed or not? """ closed = (len(self.points) > 2 and self.points[0] == self.points[-1]) return closed @property def nodes(self): """ Returns an (n,2) list of nodes, or vertices on the path. Note that this generic class function assumes that all of the reference points are on the path which is true for lines and three point arcs. If you were to define another class where that wasn't the case (for example, the control points of a bezier curve), you would need to implement an entity- specific version of this function. The purpose of having a list of nodes is so that they can then be added as edges to a graph so we can use functions to check connectivity, extract paths, etc. The slicing on this function is essentially just tiling points so the first and last vertices aren't repeated. Example: self.points = [0,1,2] returns: [[0,1], [1,2]] """ return np.column_stack((self.points, self.points)).reshape( -1)[1:-1].reshape((-1, 2)) @property def end_points(self): """ Returns the first and last points. Also note that if you define a new entity class where the first and last vertices in self.points aren't the endpoints of the curve you need to implement this function for your class. Returns ------------- ends : (2,) int Indices of the two end points of the entity """ return self.points[[0, -1]] @property def is_valid(self): """ Is the current entity valid. Returns ----------- valid : bool Is the current entity well formed """ return True def reverse(self, direction=-1): """ Reverse the current entity in place. Parameters ---------------- direction : int If positive will not touch direction If negative will reverse self.points """ if direction < 0: self._direction = -1 else: self._direction = 1 def _orient(self, curve): """ Reverse a curve if a flag is set. Parameters -------------- curve : (n, dimension) float Curve made up of line segments in space Returns ------------ orient : (n, dimension) float Original curve, but possibly reversed """ if hasattr(self, '_direction') and self._direction < 0: return curve[::-1] return curve def bounds(self, vertices): """ Return the AABB of the current entity. Parameters ----------- vertices : (n, dimension) float Vertices in space Returns ----------- bounds : (2, dimension) float Coordinates of AABB, in (min, max) form """ bounds = np.array([vertices[self.points].min(axis=0), vertices[self.points].max(axis=0)]) return bounds def length(self, vertices): """ Return the total length of the entity. Parameters -------------- vertices : (n, dimension) float Vertices in space Returns --------- length : float Total length of entity """ diff = np.diff(self.discrete(vertices), axis=0) ** 2 length = (np.dot(diff, [1] * vertices.shape[1]) ** 0.5).sum() return length def explode(self): """ Split the entity into multiple entities. Returns ------------ explode : list of Entity Current entity split into multiple entities if necessary """ return [self.copy()] def copy(self): """ Return a copy of the current entity. Returns ------------ copied : Entity Copy of current entity """ return copy.deepcopy(self) def __hash__(self): """ Return a hash that represents the current entity. Returns ---------- hashed : int Hash of current class name, points, and closed """ hashed = hash(self._bytes()) return hashed def _bytes(self): """ Get hashable bytes that define the current entity. Returns ------------ data : bytes Hashable data defining the current entity """ # give consistent ordering of points for hash if self.points[0] > self.points[-1]: return (self.__class__.__name__.encode('utf-8') + self.points.tobytes()) else: return (self.__class__.__name__.encode('utf-8') + self.points[::-1].tobytes()) class Text(Entity): """ Text to annotate a 2D or 3D path. """ def __init__(self, origin, text, height=None, vector=None, normal=None, align=None, layer=None): """ An entity for text labels. Parameters -------------- origin : int Index of a single vertex for text origin text : str The text to label height : float or None The height of text vector : int or None An vertex index for which direction text is written along unitized: vector - origin normal : int or None A vertex index for the plane normal: vector is along unitized: normal - origin align : (2,) str or None Where to draw from for [horizontal, vertical]: 'center', 'left', 'right' """ # where is text placed self.origin = origin # what direction is the text pointing self.vector = vector # what is the normal of the text plane self.normal = normal # how high is the text entity self.height = height # what layer is the entity on self.layer = layer # None or (2,) str if align is None: # if not set make everything centered align = ['center', 'center'] elif util.is_string(align): # if only one is passed set for both # horizontal and vertical align = [align, align] elif len(align) != 2: # otherwise raise rror raise ValueError('align must be (2,) str') if any(i not in ['left', 'right', 'center'] for i in align): print('nah') self.align = align # make sure text is a string if hasattr(text, 'decode'): self.text = text.decode('utf-8') else: self.text = str(text) @property def origin(self): """ The origin point of the text. Returns ----------- origin : int Index of vertices """ return self.points[0] @origin.setter def origin(self, value): value = int(value) if not hasattr(self, 'points') or self.points.ptp() == 0: self.points = np.ones(3, dtype=np.int64) * value else: self.points[0] = value @property def vector(self): """ A point representing the text direction along the vector: vertices[vector] - vertices[origin] Returns ---------- vector : int Index of vertex """ return self.points[1] @vector.setter def vector(self, value): if value is None: return self.points[1] = int(value) @property def normal(self): """ A point representing the plane normal along the vector: vertices[normal] - vertices[origin] Returns ------------ normal : int Index of vertex """ return self.points[2] @normal.setter def normal(self, value): if value is None: return self.points[2] = int(value) def plot(self, vertices, show=False): """ Plot the text using matplotlib. Parameters -------------- vertices : (n, 2) float Vertices in space show : bool If True, call plt.show() """ if vertices.shape[1] != 2: raise ValueError('only for 2D points!') import matplotlib.pyplot as plt # get rotation angle in degrees angle = np.degrees(self.angle(vertices)) # TODO: handle text size better plt.text(*vertices[self.origin], s=self.text, rotation=angle, ha=self.align[0], va=self.align[1], size=18) if show: plt.show() def angle(self, vertices): """ If Text is 2D, get the rotation angle in radians. Parameters ----------- vertices : (n, 2) float Vertices in space referenced by self.points Returns --------- angle : float Rotation angle in radians """ if vertices.shape[1] != 2: raise ValueError('angle only valid for 2D points!') # get the vector from origin direction = vertices[self.vector] - vertices[self.origin] # get the rotation angle in radians angle = np.arctan2(*direction[::-1]) return angle def length(self, vertices): return 0.0 def discrete(self, *args, **kwargs): return np.array([]) @property def closed(self): return False @property def is_valid(self): return True @property def nodes(self): return np.array([]) @property def end_points(self): return np.array([]) def _bytes(self): data = b''.join([b'Text', self.points.tobytes(), self.text.encode('utf-8')]) return data class Line(Entity): """ A line or poly-line entity """ def discrete(self, vertices, scale=1.0): """ Discretize into a world- space path. Parameters ------------ vertices: (n, dimension) float Points in space scale : float Size of overall scene for numerical comparisons Returns ------------- discrete: (m, dimension) float Path in space composed of line segments """ discrete = self._orient(vertices[self.points]) return discrete @property def is_valid(self): """ Is the current entity valid. Returns ----------- valid : bool Is the current entity well formed """ valid = np.any((self.points - self.points[0]) != 0) return valid def explode(self): """ If the current Line entity consists of multiple line break it up into n Line entities. Returns ---------- exploded: (n,) Line entities """ # copy over the current layer layer = self.layer points = np.column_stack(( self.points, self.points)).ravel()[1:-1].reshape((-1, 2)) exploded = [Line(i, layer=layer) for i in points] return exploded def _bytes(self): # give consistent ordering of points for hash if self.points[0] > self.points[-1]: return b'Line' + self.points.tobytes() else: return b'Line' + self.points[::-1].tobytes() class Arc(Entity): @property def closed(self): """ A boolean flag for whether the arc is closed (a circle) or not. Returns ---------- closed : bool If set True, Arc will be a closed circle """ if hasattr(self, '_closed'): return self._closed return False @closed.setter def closed(self, value): """ Set the Arc to be closed or not, without changing the control points Parameters ------------ value : bool Should this Arc be a closed circle or not """ self._closed = bool(value) @property def is_valid(self): """ Is the current Arc entity valid. Returns ----------- valid : bool Does the current Arc have exactly 3 control points """ return len(np.unique(self.points)) == 3 def _bytes(self): # give consistent ordering of points for hash if self.points[0] > self.points[-1]: return b'Arc' + bytes(self.closed) + self.points.tobytes() else: return b'Arc' + bytes(self.closed) + self.points[::-1].tobytes() def discrete(self, vertices, scale=1.0): """ Discretize the arc entity into line sections. Parameters ------------ vertices : (n, dimension) float Points in space scale : float Size of overall scene for numerical comparisons Returns ------------- discrete : (m, dimension) float Path in space made up of line segments """ discrete = discretize_arc(vertices[self.points], close=self.closed, scale=scale) return self._orient(discrete) def center(self, vertices): """ Return the center information about the arc entity. Parameters ------------- vertices : (n, dimension) float Vertices in space Returns ------------- info : dict With keys: 'radius', 'center' """ info = arc_center(vertices[self.points]) return info def bounds(self, vertices): """ Return the AABB of the arc entity. Parameters ----------- vertices: (n, dimension) float Vertices in space Returns ----------- bounds : (2, dimension) float Coordinates of AABB in (min, max) form """ if util.is_shape(vertices, (-1, 2)) and self.closed: # if we have a closed arc (a circle), we can return the actual bounds # this only works in two dimensions, otherwise this would return the # AABB of an sphere info = self.center(vertices) bounds = np.array([info['center'] - info['radius'], info['center'] + info['radius']], dtype=np.float64) else: # since the AABB of a partial arc is hard, approximate # the bounds by just looking at the discrete values discrete = self.discrete(vertices) bounds = np.array([discrete.min(axis=0), discrete.max(axis=0)], dtype=np.float64) return bounds class Curve(Entity): """ The parent class for all wild curves in space. """ @property def nodes(self): # a point midway through the curve mid = self.points[len(self.points) // 2] return [[self.points[0], mid], [mid, self.points[-1]]] class Bezier(Curve): """ An open or closed Bezier curve """ def discrete(self, vertices, scale=1.0, count=None): """ Discretize the Bezier curve. Parameters ------------- vertices : (n, 2) or (n, 3) float Points in space scale : float Scale of overall drawings (for precision) count : int Number of segments to return Returns ------------- discrete : (m, 2) or (m, 3) float Curve as line segments """ discrete = discretize_bezier( vertices[self.points], count=count, scale=scale) return self._orient(discrete) class BSpline(Curve): """ An open or closed B- Spline. """ def __init__(self, points, knots, closed=None, layer=None, **kwargs): self.points = np.asanyarray(points, dtype=np.int64) self.knots = np.asanyarray(knots, dtype=np.float64) self.layer = layer self.kwargs = kwargs def discrete(self, vertices, count=None, scale=1.0): """ Discretize the B-Spline curve. Parameters ------------- vertices : (n, 2) or (n, 3) float Points in space scale : float Scale of overall drawings (for precision) count : int Number of segments to return Returns ------------- discrete : (m, 2) or (m, 3) float Curve as line segments """ discrete = discretize_bspline( control=vertices[self.points], knots=self.knots, count=count, scale=scale) return self._orient(discrete) def _bytes(self): # give consistent ordering of points for hash if self.points[0] > self.points[-1]: return (b'BSpline' + self.knots.tobytes() + self.points.tobytes()) else: return (b'BSpline' + self.knots[::-1].tobytes() + self.points[::-1].tobytes()) def to_dict(self): """ Returns a dictionary with all of the information about the entity. """ return {'type': self.__class__.__name__, 'points': self.points.tolist(), 'knots': self.knots.tolist(), 'closed': self.closed}