1"""
2entities.py
3--------------
4
5Basic geometric primitives which only store references to
6vertex indices rather than vertices themselves.
7"""
8
9import numpy as np
10
11import copy
12
13from .arc import discretize_arc, arc_center
14from .curve import discretize_bezier, discretize_bspline
15
16from .. import util
17
18
19class Entity(object):
20
21    def __init__(self,
22                 points,
23                 closed=None,
24                 layer=None,
25                 color=None,
26                 **kwargs):
27        # points always reference vertex indices and are int
28        self.points = np.asanyarray(points, dtype=np.int64)
29        # save explicit closed
30        if closed is not None:
31            self.closed = closed
32        # save the passed layer
33        self.layer = layer
34        # save the passed color
35        self.color = color
36        # save any other kwargs for general use
37        self.kwargs = kwargs
38
39    def to_dict(self):
40        """
41        Returns a dictionary with all of the information
42        about the entity.
43
44        Returns
45        -----------
46        as_dict : dict
47          Has keys 'type', 'points', 'closed'
48        """
49        return {'type': self.__class__.__name__,
50                'points': self.points.tolist(),
51                'closed': self.closed}
52
53    @property
54    def closed(self):
55        """
56        If the first point is the same as the end point
57        the entity is closed
58
59        Returns
60        -----------
61        closed : bool
62          Is the entity closed or not?
63        """
64        closed = (len(self.points) > 2 and
65                  self.points[0] == self.points[-1])
66        return closed
67
68    @property
69    def nodes(self):
70        """
71        Returns an (n,2) list of nodes, or vertices on the path.
72        Note that this generic class function assumes that all of the
73        reference points are on the path which is true for lines and
74        three point arcs.
75
76        If you were to define another class where that wasn't the case
77        (for example, the control points of a bezier curve),
78        you would need to implement an entity- specific version of this
79        function.
80
81        The purpose of having a list of nodes is so that they can then be
82        added as edges to a graph so we can use functions to check
83        connectivity, extract paths, etc.
84
85        The slicing on this function is essentially just tiling points
86        so the first and last vertices aren't repeated. Example:
87
88        self.points = [0,1,2]
89        returns:      [[0,1], [1,2]]
90        """
91        return np.column_stack((self.points,
92                                self.points)).reshape(
93                                    -1)[1:-1].reshape((-1, 2))
94
95    @property
96    def end_points(self):
97        """
98        Returns the first and last points. Also note that if you
99        define a new entity class where the first and last vertices
100        in self.points aren't the endpoints of the curve you need to
101        implement this function for your class.
102
103        Returns
104        -------------
105        ends : (2,) int
106          Indices of the two end points of the entity
107        """
108        return self.points[[0, -1]]
109
110    @property
111    def is_valid(self):
112        """
113        Is the current entity valid.
114
115        Returns
116        -----------
117        valid : bool
118          Is the current entity well formed
119        """
120        return True
121
122    def reverse(self, direction=-1):
123        """
124        Reverse the current entity in place.
125
126        Parameters
127        ----------------
128        direction : int
129          If positive will not touch direction
130          If negative will reverse self.points
131        """
132        if direction < 0:
133            self._direction = -1
134        else:
135            self._direction = 1
136
137    def _orient(self, curve):
138        """
139        Reverse a curve if a flag is set.
140
141        Parameters
142        --------------
143        curve : (n, dimension) float
144          Curve made up of line segments in space
145
146        Returns
147        ------------
148        orient : (n, dimension) float
149          Original curve, but possibly reversed
150        """
151        if hasattr(self, '_direction') and self._direction < 0:
152            return curve[::-1]
153        return curve
154
155    def bounds(self, vertices):
156        """
157        Return the AABB of the current entity.
158
159        Parameters
160        -----------
161        vertices : (n, dimension) float
162          Vertices in space
163
164        Returns
165        -----------
166        bounds : (2, dimension) float
167          Coordinates of AABB, in (min, max) form
168        """
169        bounds = np.array([vertices[self.points].min(axis=0),
170                           vertices[self.points].max(axis=0)])
171        return bounds
172
173    def length(self, vertices):
174        """
175        Return the total length of the entity.
176
177        Parameters
178        --------------
179        vertices : (n, dimension) float
180          Vertices in space
181
182        Returns
183        ---------
184        length : float
185          Total length of entity
186        """
187        diff = np.diff(self.discrete(vertices), axis=0) ** 2
188        length = (np.dot(diff, [1] * vertices.shape[1]) ** 0.5).sum()
189        return length
190
191    def explode(self):
192        """
193        Split the entity into multiple entities.
194
195        Returns
196        ------------
197        explode : list of Entity
198          Current entity split into multiple entities if necessary
199        """
200        return [self.copy()]
201
202    def copy(self):
203        """
204        Return a copy of the current entity.
205
206        Returns
207        ------------
208        copied : Entity
209          Copy of current entity
210        """
211        return copy.deepcopy(self)
212
213    def __hash__(self):
214        """
215        Return a hash that represents the current entity.
216
217        Returns
218        ----------
219        hashed : int
220            Hash of current class name, points, and closed
221        """
222        hashed = hash(self._bytes())
223        return hashed
224
225    def _bytes(self):
226        """
227        Get hashable bytes that define the current entity.
228
229        Returns
230        ------------
231        data : bytes
232          Hashable data defining the current entity
233        """
234        # give consistent ordering of points for hash
235        if self.points[0] > self.points[-1]:
236            return (self.__class__.__name__.encode('utf-8') +
237                    self.points.tobytes())
238        else:
239            return (self.__class__.__name__.encode('utf-8') +
240                    self.points[::-1].tobytes())
241
242
243class Text(Entity):
244    """
245    Text to annotate a 2D or 3D path.
246    """
247
248    def __init__(self,
249                 origin,
250                 text,
251                 height=None,
252                 vector=None,
253                 normal=None,
254                 align=None,
255                 layer=None):
256        """
257        An entity for text labels.
258
259        Parameters
260        --------------
261        origin : int
262          Index of a single vertex for text origin
263        text : str
264          The text to label
265        height : float or None
266          The height of text
267        vector : int or None
268          An vertex index for which direction text
269          is written along unitized: vector - origin
270        normal : int or None
271          A vertex index for the plane normal:
272          vector is along unitized: normal - origin
273        align : (2,) str or None
274          Where to draw from for [horizontal, vertical]:
275              'center', 'left', 'right'
276        """
277        # where is text placed
278        self.origin = origin
279        # what direction is the text pointing
280        self.vector = vector
281        # what is the normal of the text plane
282        self.normal = normal
283        # how high is the text entity
284        self.height = height
285        # what layer is the entity on
286        self.layer = layer
287
288        # None or (2,) str
289        if align is None:
290            # if not set make everything centered
291            align = ['center', 'center']
292        elif util.is_string(align):
293            # if only one is passed set for both
294            # horizontal and vertical
295            align = [align, align]
296        elif len(align) != 2:
297            # otherwise raise rror
298            raise ValueError('align must be (2,) str')
299
300        if any(i not in ['left', 'right', 'center']
301               for i in align):
302            print('nah')
303
304        self.align = align
305
306        # make sure text is a string
307        if hasattr(text, 'decode'):
308            self.text = text.decode('utf-8')
309        else:
310            self.text = str(text)
311
312    @property
313    def origin(self):
314        """
315        The origin point of the text.
316
317        Returns
318        -----------
319        origin : int
320          Index of vertices
321        """
322        return self.points[0]
323
324    @origin.setter
325    def origin(self, value):
326        value = int(value)
327        if not hasattr(self, 'points') or self.points.ptp() == 0:
328            self.points = np.ones(3, dtype=np.int64) * value
329        else:
330            self.points[0] = value
331
332    @property
333    def vector(self):
334        """
335        A point representing the text direction
336        along the vector: vertices[vector] - vertices[origin]
337
338        Returns
339        ----------
340        vector : int
341          Index of vertex
342        """
343        return self.points[1]
344
345    @vector.setter
346    def vector(self, value):
347        if value is None:
348            return
349        self.points[1] = int(value)
350
351    @property
352    def normal(self):
353        """
354        A point representing the plane normal along the
355        vector: vertices[normal] - vertices[origin]
356
357        Returns
358        ------------
359        normal : int
360          Index of vertex
361        """
362        return self.points[2]
363
364    @normal.setter
365    def normal(self, value):
366        if value is None:
367            return
368        self.points[2] = int(value)
369
370    def plot(self, vertices, show=False):
371        """
372        Plot the text using matplotlib.
373
374        Parameters
375        --------------
376        vertices : (n, 2) float
377          Vertices in space
378        show : bool
379          If True, call plt.show()
380        """
381        if vertices.shape[1] != 2:
382            raise ValueError('only for 2D points!')
383
384        import matplotlib.pyplot as plt
385
386        # get rotation angle in degrees
387        angle = np.degrees(self.angle(vertices))
388
389        # TODO: handle text size better
390        plt.text(*vertices[self.origin],
391                 s=self.text,
392                 rotation=angle,
393                 ha=self.align[0],
394                 va=self.align[1],
395                 size=18)
396
397        if show:
398            plt.show()
399
400    def angle(self, vertices):
401        """
402        If Text is 2D, get the rotation angle in radians.
403
404        Parameters
405        -----------
406        vertices : (n, 2) float
407          Vertices in space referenced by self.points
408
409        Returns
410        ---------
411        angle : float
412          Rotation angle in radians
413        """
414
415        if vertices.shape[1] != 2:
416            raise ValueError('angle only valid for 2D points!')
417
418        # get the vector from origin
419        direction = vertices[self.vector] - vertices[self.origin]
420        # get the rotation angle in radians
421        angle = np.arctan2(*direction[::-1])
422
423        return angle
424
425    def length(self, vertices):
426        return 0.0
427
428    def discrete(self, *args, **kwargs):
429        return np.array([])
430
431    @property
432    def closed(self):
433        return False
434
435    @property
436    def is_valid(self):
437        return True
438
439    @property
440    def nodes(self):
441        return np.array([])
442
443    @property
444    def end_points(self):
445        return np.array([])
446
447    def _bytes(self):
448        data = b''.join([b'Text',
449                         self.points.tobytes(),
450                         self.text.encode('utf-8')])
451        return data
452
453
454class Line(Entity):
455    """
456    A line or poly-line entity
457    """
458
459    def discrete(self, vertices, scale=1.0):
460        """
461        Discretize into a world- space path.
462
463        Parameters
464        ------------
465        vertices: (n, dimension) float
466          Points in space
467        scale : float
468          Size of overall scene for numerical comparisons
469
470        Returns
471        -------------
472        discrete: (m, dimension) float
473          Path in space composed of line segments
474        """
475        discrete = self._orient(vertices[self.points])
476        return discrete
477
478    @property
479    def is_valid(self):
480        """
481        Is the current entity valid.
482
483        Returns
484        -----------
485        valid : bool
486          Is the current entity well formed
487        """
488        valid = np.any((self.points - self.points[0]) != 0)
489        return valid
490
491    def explode(self):
492        """
493        If the current Line entity consists of multiple line
494        break it up into n Line entities.
495
496        Returns
497        ----------
498        exploded: (n,) Line entities
499        """
500        # copy over the current layer
501        layer = self.layer
502        points = np.column_stack((
503            self.points,
504            self.points)).ravel()[1:-1].reshape((-1, 2))
505        exploded = [Line(i, layer=layer) for i in points]
506        return exploded
507
508    def _bytes(self):
509        # give consistent ordering of points for hash
510        if self.points[0] > self.points[-1]:
511            return b'Line' + self.points.tobytes()
512        else:
513            return b'Line' + self.points[::-1].tobytes()
514
515
516class Arc(Entity):
517
518    @property
519    def closed(self):
520        """
521        A boolean flag for whether the arc is closed (a circle) or not.
522
523        Returns
524        ----------
525        closed : bool
526          If set True, Arc will be a closed circle
527        """
528        if hasattr(self, '_closed'):
529            return self._closed
530        return False
531
532    @closed.setter
533    def closed(self, value):
534        """
535        Set the Arc to be closed or not, without
536        changing the control points
537
538        Parameters
539        ------------
540        value : bool
541          Should this Arc be a closed circle or not
542        """
543        self._closed = bool(value)
544
545    @property
546    def is_valid(self):
547        """
548        Is the current Arc entity valid.
549
550        Returns
551        -----------
552        valid : bool
553          Does the current Arc have exactly 3 control points
554        """
555        return len(np.unique(self.points)) == 3
556
557    def _bytes(self):
558        # give consistent ordering of points for hash
559        if self.points[0] > self.points[-1]:
560            return b'Arc' + bytes(self.closed) + self.points.tobytes()
561        else:
562            return b'Arc' + bytes(self.closed) + self.points[::-1].tobytes()
563
564    def discrete(self, vertices, scale=1.0):
565        """
566        Discretize the arc entity into line sections.
567
568        Parameters
569        ------------
570        vertices : (n, dimension) float
571            Points in space
572        scale : float
573            Size of overall scene for numerical comparisons
574
575        Returns
576        -------------
577        discrete : (m, dimension) float
578          Path in space made up of line segments
579        """
580        discrete = discretize_arc(vertices[self.points],
581                                  close=self.closed,
582                                  scale=scale)
583        return self._orient(discrete)
584
585    def center(self, vertices):
586        """
587        Return the center information about the arc entity.
588
589        Parameters
590        -------------
591        vertices : (n, dimension) float
592          Vertices in space
593
594        Returns
595        -------------
596        info : dict
597          With keys: 'radius', 'center'
598        """
599        info = arc_center(vertices[self.points])
600        return info
601
602    def bounds(self, vertices):
603        """
604        Return the AABB of the arc entity.
605
606        Parameters
607        -----------
608        vertices: (n, dimension) float
609          Vertices in space
610
611        Returns
612        -----------
613        bounds : (2, dimension) float
614          Coordinates of AABB in (min, max) form
615        """
616        if util.is_shape(vertices, (-1, 2)) and self.closed:
617            # if we have a closed arc (a circle), we can return the actual bounds
618            # this only works in two dimensions, otherwise this would return the
619            # AABB of an sphere
620            info = self.center(vertices)
621            bounds = np.array([info['center'] - info['radius'],
622                               info['center'] + info['radius']],
623                              dtype=np.float64)
624        else:
625            # since the AABB of a partial arc is hard, approximate
626            # the bounds by just looking at the discrete values
627            discrete = self.discrete(vertices)
628            bounds = np.array([discrete.min(axis=0),
629                               discrete.max(axis=0)],
630                              dtype=np.float64)
631        return bounds
632
633
634class Curve(Entity):
635    """
636    The parent class for all wild curves in space.
637    """
638    @property
639    def nodes(self):
640        # a point midway through the curve
641        mid = self.points[len(self.points) // 2]
642        return [[self.points[0], mid],
643                [mid, self.points[-1]]]
644
645
646class Bezier(Curve):
647    """
648    An open or closed Bezier curve
649    """
650
651    def discrete(self, vertices, scale=1.0, count=None):
652        """
653        Discretize the Bezier curve.
654
655        Parameters
656        -------------
657        vertices : (n, 2) or (n, 3) float
658          Points in space
659        scale : float
660          Scale of overall drawings (for precision)
661        count : int
662          Number of segments to return
663
664        Returns
665        -------------
666        discrete : (m, 2) or (m, 3) float
667          Curve as line segments
668        """
669        discrete = discretize_bezier(
670            vertices[self.points],
671            count=count,
672            scale=scale)
673        return self._orient(discrete)
674
675
676class BSpline(Curve):
677    """
678    An open or closed B- Spline.
679    """
680
681    def __init__(self, points,
682                 knots,
683                 closed=None,
684                 layer=None,
685                 **kwargs):
686        self.points = np.asanyarray(points, dtype=np.int64)
687        self.knots = np.asanyarray(knots, dtype=np.float64)
688        self.layer = layer
689        self.kwargs = kwargs
690
691    def discrete(self, vertices, count=None, scale=1.0):
692        """
693        Discretize the B-Spline curve.
694
695        Parameters
696        -------------
697        vertices : (n, 2) or (n, 3) float
698          Points in space
699        scale : float
700          Scale of overall drawings (for precision)
701        count : int
702          Number of segments to return
703
704        Returns
705        -------------
706        discrete : (m, 2) or (m, 3) float
707          Curve as line segments
708        """
709        discrete = discretize_bspline(
710            control=vertices[self.points],
711            knots=self.knots,
712            count=count,
713            scale=scale)
714        return self._orient(discrete)
715
716    def _bytes(self):
717        # give consistent ordering of points for hash
718        if self.points[0] > self.points[-1]:
719            return (b'BSpline' +
720                    self.knots.tobytes() +
721                    self.points.tobytes())
722        else:
723            return (b'BSpline' +
724                    self.knots[::-1].tobytes() +
725                    self.points[::-1].tobytes())
726
727    def to_dict(self):
728        """
729        Returns a dictionary with all of the information
730        about the entity.
731        """
732        return {'type': self.__class__.__name__,
733                'points': self.points.tolist(),
734                'knots': self.knots.tolist(),
735                'closed': self.closed}
736