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