Coverage for trimesh/path/entities.py: 90%
240 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
1"""
2entities.py
3--------------
5Basic geometric primitives which only store references to
6vertex indices rather than vertices themselves.
7"""
9from copy import deepcopy
10from logging import getLogger
12import numpy as np
14from .. import util
15from ..util import ABC
16from .arc import arc_center, discretize_arc
17from .curve import discretize_bezier, discretize_bspline
19log = getLogger(__name__)
22class Entity(ABC):
23 def __init__(
24 self, points, closed=None, layer=None, metadata=None, color=None, **kwargs
25 ):
26 # points always reference vertex indices and are int
27 self.points = np.asanyarray(points, dtype=np.int64)
28 # save explicit closed
29 if closed is not None:
30 self.closed = closed
31 # save the passed layer
32 if layer is not None:
33 self.layer = layer
34 if metadata is not None:
35 self.metadata.update(metadata)
37 self._cache = {}
39 # save the passed color
40 self.color = color
41 # save any other kwargs for general use
42 self.kwargs = kwargs
44 @property
45 def metadata(self):
46 """
47 Get any metadata about the entity.
49 Returns
50 ---------
51 metadata : dict
52 Bag of properties.
53 """
54 if not hasattr(self, "_metadata"):
55 self._metadata = {}
56 # note that we don't let a new dict be assigned
57 return self._metadata
59 @property
60 def layer(self):
61 """
62 Set the layer the entity resides on as a shortcut
63 to putting it in the entity metadata.
65 Returns
66 ----------
67 layer : any
68 Hashable layer identifier.
69 """
70 return self.metadata.get("layer")
72 @layer.setter
73 def layer(self, value):
74 """
75 Set the current layer of the entity.
77 Returns
78 ----------
79 layer : any
80 Hashable layer indicator
81 """
82 self.metadata["layer"] = value
84 def to_dict(self) -> dict:
85 """
86 Returns a dictionary with all of the information
87 about the entity.
89 Returns
90 -----------
91 as_dict : dict
92 Has keys 'type', 'points', 'closed'
93 """
94 return {
95 "type": self.__class__.__name__,
96 "points": self.points.tolist(),
97 "closed": self.closed,
98 }
100 @property
101 def closed(self):
102 """
103 If the first point is the same as the end point
104 the entity is closed
106 Returns
107 -----------
108 closed : bool
109 Is the entity closed or not?
110 """
111 return len(self.points) > 2 and self.points[0] == self.points[-1]
113 @property
114 def nodes(self):
115 """
116 Returns an (n,2) list of nodes, or vertices on the path.
117 Note that this generic class function assumes that all of the
118 reference points are on the path which is true for lines and
119 three point arcs.
121 If you were to define another class where that wasn't the case
122 (for example, the control points of a bezier curve),
123 you would need to implement an entity- specific version of this
124 function.
126 The purpose of having a list of nodes is so that they can then be
127 added as edges to a graph so we can use functions to check
128 connectivity, extract paths, etc.
130 The slicing on this function is essentially just tiling points
131 so the first and last vertices aren't repeated. Example:
133 self.points = [0,1,2]
134 returns: [[0,1], [1,2]]
135 """
136 return (
137 np.column_stack((self.points, self.points)).reshape(-1)[1:-1].reshape((-1, 2))
138 )
140 @property
141 def end_points(self):
142 """
143 Returns the first and last points. Also note that if you
144 define a new entity class where the first and last vertices
145 in self.points aren't the endpoints of the curve you need to
146 implement this function for your class.
148 Returns
149 -------------
150 ends : (2,) int
151 Indices of the two end points of the entity
152 """
153 return self.points[[0, -1]]
155 @property
156 def is_valid(self):
157 """
158 Is the current entity valid.
160 Returns
161 -----------
162 valid : bool
163 Is the current entity well formed
164 """
165 return True
167 def reverse(self, direction=-1):
168 """
169 Reverse the current entity in place.
171 Parameters
172 ----------------
173 direction : int
174 If positive will not touch direction
175 If negative will reverse self.points
176 """
177 if direction < 0:
178 self._direction = -1
179 else:
180 self._direction = 1
182 def _orient(self, curve):
183 """
184 Reverse a curve if a flag is set.
186 Parameters
187 --------------
188 curve : (n, dimension) float
189 Curve made up of line segments in space
191 Returns
192 ------------
193 orient : (n, dimension) float
194 Original curve, but possibly reversed
195 """
196 if hasattr(self, "_direction") and self._direction < 0:
197 return curve[::-1]
198 return curve
200 def bounds(self, vertices):
201 """
202 Return the AABB of the current entity.
204 Parameters
205 -----------
206 vertices : (n, dimension) float
207 Vertices in space
209 Returns
210 -----------
211 bounds : (2, dimension) float
212 Coordinates of AABB, in (min, max) form
213 """
214 bounds = np.array(
215 [vertices[self.points].min(axis=0), vertices[self.points].max(axis=0)]
216 )
217 return bounds
219 def length(self, vertices):
220 """
221 Return the total length of the entity.
223 Parameters
224 --------------
225 vertices : (n, dimension) float
226 Vertices in space
228 Returns
229 ---------
230 length : float
231 Total length of entity
232 """
233 diff = np.diff(self.discrete(vertices), axis=0) ** 2
234 length = (np.dot(diff, [1] * vertices.shape[1]) ** 0.5).sum()
235 return length
237 def explode(self):
238 """
239 Split the entity into multiple entities.
241 Returns
242 ------------
243 explode : list of Entity
244 Current entity split into multiple entities.
245 """
246 return [self.copy()]
248 def copy(self):
249 """
250 Return a copy of the current entity.
252 Returns
253 ------------
254 copied : Entity
255 Copy of current entity
256 """
257 copied = deepcopy(self)
258 # only copy metadata if set
259 if hasattr(self, "_metadata"):
260 copied._metadata = deepcopy(self._metadata)
261 # check for very annoying subtle copy failures
262 assert id(copied._metadata) != id(self._metadata)
263 assert id(copied.points) != id(self.points)
264 return copied
266 def __hash__(self):
267 """
268 Return a hash that represents the current entity.
270 Returns
271 ----------
272 hashed : int
273 Hash of current class name, points, and closed
274 """
275 return hash(self._bytes())
277 def _bytes(self):
278 """
279 Get hashable bytes that define the current entity.
281 Returns
282 ------------
283 data : bytes
284 Hashable data defining the current entity
285 """
286 # give consistent ordering of points for hash
287 if self.points[0] > self.points[-1]:
288 return self.__class__.__name__.encode("utf-8") + self.points.tobytes()
289 else:
290 return self.__class__.__name__.encode("utf-8") + self.points[::-1].tobytes()
293class Text(Entity):
294 """
295 Text to annotate a 2D or 3D path.
296 """
298 def __init__(
299 self,
300 origin,
301 text,
302 height=None,
303 vector=None,
304 normal=None,
305 align=None,
306 layer=None,
307 color=None,
308 metadata=None,
309 ):
310 """
311 An entity for text labels.
313 Parameters
314 --------------
315 origin : int
316 Index of a single vertex for text origin
317 text : str
318 The text to label
319 height : float or None
320 The height of text
321 vector : int or None
322 An vertex index for which direction text
323 is written along unitized: vector - origin
324 normal : int or None
325 A vertex index for the plane normal:
326 vector is along unitized: normal - origin
327 align : (2,) str or None
328 Where to draw from for [horizontal, vertical]:
329 'center', 'left', 'right'
330 """
331 # where is text placed
332 self.origin = origin
333 # what direction is the text pointing
334 self.vector = vector
335 # what is the normal of the text plane
336 self.normal = normal
337 # how high is the text entity
338 self.height = height
339 # what layer is the entity on
340 if layer is not None:
341 self.layer = layer
343 if metadata is not None:
344 self.metadata.update(metadata)
346 # what color is the entity
347 self.color = color
349 # None or (2,) str
350 if align is None:
351 # if not set make everything centered
352 align = ["center", "center"]
353 elif isinstance(align, str):
354 # if only one is passed set for both
355 # horizontal and vertical
356 align = [align, align]
357 elif len(align) != 2:
358 # otherwise raise rror
359 raise ValueError("align must be (2,) str")
361 self.align = align
363 # make sure text is a string
364 if hasattr(text, "decode"):
365 self.text = text.decode("utf-8")
366 else:
367 self.text = str(text)
369 @property
370 def origin(self):
371 """
372 The origin point of the text.
374 Returns
375 -----------
376 origin : int
377 Index of vertices
378 """
379 return self.points[0]
381 @origin.setter
382 def origin(self, value):
383 value = int(value)
384 if not hasattr(self, "points") or np.ptp(self.points) == 0:
385 self.points = np.ones(3, dtype=np.int64) * value
386 else:
387 self.points[0] = value
389 @property
390 def vector(self):
391 """
392 A point representing the text direction
393 along the vector: vertices[vector] - vertices[origin]
395 Returns
396 ----------
397 vector : int
398 Index of vertex
399 """
400 return self.points[1]
402 @vector.setter
403 def vector(self, value):
404 if value is None:
405 return
406 self.points[1] = int(value)
408 @property
409 def normal(self):
410 """
411 A point representing the plane normal along the
412 vector: vertices[normal] - vertices[origin]
414 Returns
415 ------------
416 normal : int
417 Index of vertex
418 """
419 return self.points[2]
421 @normal.setter
422 def normal(self, value):
423 if value is None:
424 return
425 self.points[2] = int(value)
427 def plot(self, vertices, show=False):
428 """
429 Plot the text using matplotlib.
431 Parameters
432 --------------
433 vertices : (n, 2) float
434 Vertices in space
435 show : bool
436 If True, call plt.show()
437 """
438 if vertices.shape[1] != 2:
439 raise ValueError("only for 2D points!")
441 import matplotlib.pyplot as plt # noqa
443 # get rotation angle in degrees
444 angle = np.degrees(self.angle(vertices))
446 # TODO: handle text size better
447 plt.text(
448 *vertices[self.origin],
449 s=self.text,
450 rotation=angle,
451 ha=self.align[0],
452 va=self.align[1],
453 size=18,
454 )
456 if show:
457 plt.show()
459 def angle(self, vertices):
460 """
461 If Text is 2D, get the rotation angle in radians.
463 Parameters
464 -----------
465 vertices : (n, 2) float
466 Vertices in space referenced by self.points
468 Returns
469 ---------
470 angle : float
471 Rotation angle in radians
472 """
474 if vertices.shape[1] != 2:
475 raise ValueError("angle only valid for 2D points!")
477 # get the vector from origin
478 direction = vertices[self.vector] - vertices[self.origin]
479 # get the rotation angle in radians
480 angle = np.arctan2(*direction[::-1])
482 return angle
484 def length(self, vertices):
485 return 0.0
487 def discrete(self, *args, **kwargs):
488 return np.array([])
490 @property
491 def closed(self):
492 return False
494 @property
495 def is_valid(self):
496 return True
498 @property
499 def nodes(self):
500 return np.array([])
502 @property
503 def end_points(self):
504 return np.array([])
506 def _bytes(self):
507 data = b"".join([b"Text", self.points.tobytes(), self.text.encode("utf-8")])
508 return data
511class Line(Entity):
512 """
513 A line or poly-line entity
514 """
516 def discrete(self, vertices, scale=1.0):
517 """
518 Discretize into a world- space path.
520 Parameters
521 ------------
522 vertices: (n, dimension) float
523 Points in space
524 scale : float
525 Size of overall scene for numerical comparisons
527 Returns
528 -------------
529 discrete: (m, dimension) float
530 Path in space composed of line segments
531 """
532 return self._orient(vertices[self.points])
534 @property
535 def closed(self):
536 return len(self.points) > 2 and self.points[0] == self.points[-1]
538 @closed.setter
539 def closed(self, value: bool):
540 current = self.points[0] == self.points[-1]
541 if value and not current:
542 # case where we've been asked to close the line
543 # this seems pretty obvious that we should just append the first pointOB
544 self.points = np.concatenate((self.points, [self.points[0]]))
545 elif not value and current:
546 # case where we've been asked to *disconnect* a closed path
547 log.debug("ignoring `Line.closed = False`")
549 @property
550 def is_valid(self):
551 """
552 Is the current entity valid.
554 Returns
555 -----------
556 valid : bool
557 Is the current entity well formed
558 """
559 valid = np.any((self.points - self.points[0]) != 0)
560 return valid
562 def explode(self):
563 """
564 If the current Line entity consists of multiple line
565 break it up into n Line entities.
567 Returns
568 ----------
569 exploded: (n,) Line entities
570 """
571 # copy over the current layer
572 layer = self.layer
573 points = (
574 np.column_stack((self.points, self.points)).ravel()[1:-1].reshape((-1, 2))
575 )
576 exploded = [Line(i, layer=layer) for i in points]
577 return exploded
579 def _bytes(self):
580 # give consistent ordering of points for hash
581 if self.points[0] > self.points[-1]:
582 return b"Line" + self.points.tobytes()
583 else:
584 return b"Line" + self.points[::-1].tobytes()
586 def to_dict(self) -> dict:
587 """
588 Returns a dictionary with all of the information
589 about the Line. `closed` is not additional information
590 for a Line like it is for Arc where the value determines
591 if it is a partial or complete circle. Rather it is a check
592 which indicates the first and last points are identical,
593 and thus should not be included in the export
595 Returns
596 -----------
597 as_dict
598 Has keys 'type', 'points'
599 """
600 return {
601 "type": self.__class__.__name__,
602 "points": self.points.tolist(),
603 }
606class Arc(Entity):
607 @property
608 def closed(self):
609 """
610 A boolean flag for whether the arc is closed (a circle) or not.
612 Returns
613 ----------
614 closed : bool
615 If set True, Arc will be a closed circle
616 """
617 return getattr(self, "_closed", False)
619 @closed.setter
620 def closed(self, value):
621 """
622 Set the Arc to be closed or not, without
623 changing the control points
625 Parameters
626 ------------
627 value : bool
628 Should this Arc be a closed circle or not
629 """
630 self._closed = bool(value)
632 @property
633 def is_valid(self):
634 """
635 Is the current Arc entity valid.
637 Returns
638 -----------
639 valid : bool
640 Does the current Arc have exactly 3 control points
641 """
642 return len(np.unique(self.points)) == 3
644 def _bytes(self):
645 # give consistent ordering of points for hash
646 order = int(self.points[0] > self.points[-1]) * 2 - 1
647 return b"Arc" + bytes(self.closed) + self.points[::order].tobytes()
649 def length(self, vertices):
650 """
651 Return the arc length of the 3-point arc.
653 Parameter
654 ----------
655 vertices : (n, d) float
656 Vertices for overall drawing.
658 Returns
659 -----------
660 length : float
661 Length of arc.
662 """
663 # find the actual radius and angle span
664 if self.closed:
665 # we don't need the angular span as
666 # it's indicated as a closed circle
667 fit = self.center(vertices, return_normal=False, return_angle=False)
668 return np.pi * fit.radius * 4
669 # get the angular span of the circular arc
670 fit = self.center(vertices, return_normal=False, return_angle=True)
671 return fit.span * fit.radius * 2
673 def discrete(self, vertices, scale=1.0):
674 """
675 Discretize the arc entity into line sections.
677 Parameters
678 ------------
679 vertices : (n, dimension) float
680 Points in space
681 scale : float
682 Size of overall scene for numerical comparisons
684 Returns
685 -------------
686 discrete : (m, dimension) float
687 Path in space made up of line segments
688 """
690 return self._orient(
691 discretize_arc(vertices[self.points], close=self.closed, scale=scale)
692 )
694 def center(self, vertices, **kwargs):
695 """
696 Return the center information about the arc entity.
698 Parameters
699 -------------
700 vertices : (n, dimension) float
701 Vertices in space
703 Returns
704 -------------
705 info : dict
706 With keys: 'radius', 'center'
707 """
708 return arc_center(vertices[self.points], **kwargs)
710 def bounds(self, vertices):
711 """
712 Return the AABB of the arc entity.
714 Parameters
715 -----------
716 vertices: (n, dimension) float
717 Vertices in space
719 Returns
720 -----------
721 bounds : (2, dimension) float
722 Coordinates of AABB in (min, max) form
723 """
724 if util.is_shape(vertices, (-1, 2)) and self.closed:
725 # if we have a closed arc (a circle), we can return the actual bounds
726 # this only works in two dimensions, otherwise this would return the
727 # AABB of an sphere
728 info = self.center(vertices, return_normal=False, return_angle=False)
729 bounds = np.array(
730 [info.center - info.radius, info.center + info.radius], dtype=np.float64
731 )
732 else:
733 # since the AABB of a partial arc is hard, approximate
734 # the bounds by just looking at the discrete values
735 discrete = self.discrete(vertices)
736 bounds = np.array(
737 [discrete.min(axis=0), discrete.max(axis=0)], dtype=np.float64
738 )
739 return bounds
742class Curve(Entity):
743 """
744 The parent class for all wild curves in space.
745 """
747 @property
748 def nodes(self):
749 # a point midway through the curve
750 mid = self.points[len(self.points) // 2]
751 return [[self.points[0], mid], [mid, self.points[-1]]]
754class Bezier(Curve):
755 """
756 An open or closed Bezier curve
757 """
759 def discrete(self, vertices, scale=1.0, count=None):
760 """
761 Discretize the Bezier curve.
763 Parameters
764 -------------
765 vertices : (n, 2) or (n, 3) float
766 Points in space
767 scale : float
768 Scale of overall drawings (for precision)
769 count : int
770 Number of segments to return
772 Returns
773 -------------
774 discrete : (m, 2) or (m, 3) float
775 Curve as line segments
776 """
777 return self._orient(
778 discretize_bezier(vertices[self.points], count=count, scale=scale)
779 )
782class BSpline(Curve):
783 """
784 An open or closed B- Spline.
785 """
787 def __init__(self, points, knots, layer=None, metadata=None, color=None, **kwargs):
788 self.points = np.asanyarray(points, dtype=np.int64)
789 self.knots = np.asanyarray(knots, dtype=np.float64)
790 if layer is not None:
791 self.layer = layer
792 if metadata is not None:
793 self.metadata.update(metadata)
794 self._cache = {}
795 self.kwargs = kwargs
796 self.color = color
798 def discrete(self, vertices, count=None, scale=1.0):
799 """
800 Discretize the B-Spline curve.
802 Parameters
803 -------------
804 vertices : (n, 2) or (n, 3) float
805 Points in space
806 scale : float
807 Scale of overall drawings (for precision)
808 count : int
809 Number of segments to return
811 Returns
812 -------------
813 discrete : (m, 2) or (m, 3) float
814 Curve as line segments
815 """
816 discrete = discretize_bspline(
817 control=vertices[self.points], knots=self.knots, count=count, scale=scale
818 )
819 return self._orient(discrete)
821 def _bytes(self):
822 # give consistent ordering of points for hash
823 if self.points[0] > self.points[-1]:
824 return b"BSpline" + self.knots.tobytes() + self.points.tobytes()
825 else:
826 return b"BSpline" + self.knots[::-1].tobytes() + self.points[::-1].tobytes()
828 def to_dict(self) -> dict:
829 """
830 Returns a dictionary with all of the information
831 about the entity.
832 """
833 return {
834 "type": self.__class__.__name__,
835 "points": self.points.tolist(),
836 "knots": self.knots.tolist(),
837 "closed": self.closed,
838 }