Coverage for trimesh/path/path.py: 87%
508 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-31 23:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-31 23:55 +0000
1"""
2path.py
3-----------
5A module designed to work with vector paths such as
6those stored in a DXF or SVG file.
7"""
9import collections
10import warnings
11from copy import deepcopy
12from hashlib import sha256
14import numpy as np
16from .. import (
17 bounds,
18 caching,
19 comparison,
20 convex,
21 exceptions,
22 grouping,
23 parent,
24 units,
25 util,
26)
27from .. import transformations as tf
28from ..caching import cache_decorator
29from ..constants import log
30from ..constants import tol_path as tol
31from ..geometry import plane_transform
32from ..points import plane_fit
33from ..typed import (
34 ArrayLike,
35 Iterable,
36 Mapping,
37 NDArray,
38 Self,
39 float64,
40)
41from ..visual import to_rgba
42from . import (
43 creation, # NOQA
44 raster,
45 segments, # NOQA
46 simplify,
47 traversal,
48)
49from .entities import Entity
50from .exchange.export import export_path
51from .util import concatenate
53# now import things which require non-minimal install of Trimesh
54# create a dummy module which will raise the ImportError
55# or other exception only when someone tries to use that function
56try:
57 from . import repair
58except BaseException as E:
59 repair = exceptions.ExceptionWrapper(E)
60try:
61 from . import polygons
62except BaseException as E:
63 polygons = exceptions.ExceptionWrapper(E)
64try:
65 from scipy.spatial import cKDTree
66except BaseException as E:
67 cKDTree = exceptions.ExceptionWrapper(E)
68try:
69 from shapely.geometry import Polygon
70except BaseException as E:
71 Polygon = exceptions.ExceptionWrapper(E)
73try:
74 import networkx as nx
75except BaseException as E:
76 nx = exceptions.ExceptionWrapper(E)
79class Path(parent.Geometry):
80 """
81 A Path object consists of vertices and entities. Vertices
82 are a simple (n, dimension) float array of points in space.
84 Entities are a list of objects representing geometric
85 primitives, such as Lines, Arcs, BSpline, etc. All entities
86 reference vertices by index, so any transform applied to the
87 simple vertex array is applied to the entity.
88 """
90 def __init__(
91 self,
92 entities: ArrayLike | Iterable[Entity] | None = None,
93 vertices: ArrayLike | None = None,
94 metadata: Mapping | None = None,
95 process: bool = True,
96 colors: ArrayLike | None = None,
97 vertex_attributes: Mapping | None = None,
98 **kwargs,
99 ):
100 """
101 Instantiate a path object.
103 Parameters
104 -----------
105 entities : (m,) trimesh.path.entities.Entity
106 Contains geometric entities
107 vertices : (n, dimension) float
108 The vertices referenced by entities
109 metadata : dict
110 Any metadata about the path
111 process : bool
112 Run simple cleanup or not
113 colors
114 Set any per-entity colors.
115 vertex_attributes
116 Set any per-vertex array data.
117 """
119 self.entities = entities
120 self.vertices = vertices
122 # assign each color to each entity
123 self.colors = colors
124 # collect metadata
125 self.metadata = {}
126 if isinstance(metadata, dict):
127 self.metadata.update(metadata)
129 self.vertex_attributes = {}
130 if vertex_attributes is not None:
131 self.vertex_attributes.update(vertex_attributes)
133 # cache will dump whenever self.crc changes
134 self._cache = caching.Cache(id_function=self.__hash__)
136 if process:
137 # if our input had disconnected but identical points
138 # pretty much nothing will work if vertices aren't merged properly
139 self.merge_vertices()
141 def __repr__(self):
142 """
143 Print a quick summary of the number of vertices and entities.
144 """
145 return f"<trimesh.{type(self).__name__}(vertices.shape={self.vertices.shape}, len(entities)={len(self.entities)})>"
147 def process(self) -> Self:
148 """
149 Apply basic cleaning functions to the Path object in-place.
150 """
151 with self._cache:
152 self.merge_vertices()
153 self.remove_duplicate_entities()
154 self.remove_unreferenced_vertices()
155 return self
157 @property
158 def colors(self) -> NDArray | None:
159 """
160 Colors are stored per-entity.
162 Returns
163 ------------
164 colors : (len(entities), 4) uint8
165 RGBA colors for each entity
166 """
167 # start with default colors
168 raw = [e.color for e in self.entities]
169 if not any(c is not None for c in raw):
170 return None
172 colors = np.array([to_rgba(c) for c in raw])
173 # don't allow parts of the color array to be written
174 colors.flags["WRITEABLE"] = False
175 return colors
177 @colors.setter
178 def colors(self, values: ArrayLike | None):
179 """
180 Set the color for every entity in the Path.
182 Parameters
183 ------------
184 values : (len(entities), 4) uint8
185 Color of each entity
186 """
187 # if not set return
188 if values is None:
189 return
190 # make sure colors are RGBA
191 colors = to_rgba(values)
192 if len(colors) != len(self.entities):
193 raise ValueError("colors must be per-entity!")
194 # otherwise assign each color to the entity
195 for c, e in zip(colors, self.entities):
196 e.color = c
198 @property
199 def vertices(self) -> NDArray[float64]:
200 return self._vertices
202 @vertices.setter
203 def vertices(self, values: ArrayLike | None):
204 if values is None:
205 self._vertices = caching.tracked_array([], dtype=np.float64)
206 else:
207 self._vertices = caching.tracked_array(values, dtype=np.float64)
209 @property
210 def entities(self):
211 """
212 The actual entities making up the path.
214 Returns
215 -----------
216 entities : (n,) trimesh.path.entities.Entity
217 Entities such as Line, Arc, or BSpline curves
218 """
219 return self._entities
221 @entities.setter
222 def entities(self, values):
223 if values is None:
224 self._entities = np.array([])
225 else:
226 self._entities = np.asanyarray(values)
228 @property
229 def layers(self):
230 """
231 Get a list of the layer for every entity.
233 Returns
234 ---------
235 layers : (len(entities), ) any
236 Whatever is stored in each `entity.layer`
237 """
238 # layer is a required meta-property for entities
239 return [e.layer for e in self.entities]
241 def __hash__(self):
242 """
243 A hash of the current vertices and entities.
245 Returns
246 ------------
247 hash : long int
248 Appended hashes
249 """
250 # get the hash of the trackedarray vertices
251 hashable = [hex(self.vertices.__hash__()).encode("utf-8")]
252 # get the bytes for each entity
253 hashable.extend(e._bytes() for e in self.entities)
254 # hash the combined result
255 return caching.hash_fast(b"".join(hashable))
257 @cache_decorator
258 def identifier_hash(self):
259 """
260 Return a hash of the identifier.
262 Returns
263 ----------
264 hashed : (64,) str
265 SHA256 hash of the identifier vector.
266 """
267 as_int = (self.identifier * 1e4).astype(np.int64)
268 return sha256(as_int.tobytes(order="C")).hexdigest()
270 @cache_decorator
271 def paths(self):
272 """
273 Sequence of closed paths, encoded by entity index.
275 Returns
276 ---------
277 paths : (n,) sequence of (*,) int
278 Referencing self.entities
279 """
280 paths = traversal.closed_paths(self.entities, self.vertices)
281 return paths
283 @cache_decorator
284 def dangling(self):
285 """
286 List of entities that aren't included in a closed path
288 Returns
289 ----------
290 dangling : (n,) int
291 Index of self.entities
292 """
293 if len(self.paths) == 0:
294 return np.arange(len(self.entities))
296 return np.setdiff1d(np.arange(len(self.entities)), np.hstack(self.paths))
298 @cache_decorator
299 def kdtree(self):
300 """
301 A KDTree object holding the vertices of the path.
303 Returns
304 ----------
305 kdtree : scipy.spatial.cKDTree
306 Object holding self.vertices
307 """
308 kdtree = cKDTree(self.vertices.view(np.ndarray))
309 return kdtree
311 @cache_decorator
312 def length(self):
313 """
314 The total discretized length of every entity.
316 Returns
317 --------
318 length : float
319 Summed length of every entity
320 """
321 length = float(sum(i.length(self.vertices) for i in self.entities))
322 return length
324 @cache_decorator
325 def bounds(self):
326 """
327 Return the axis aligned bounding box of the current path.
329 Returns
330 ----------
331 bounds : (2, dimension) float
332 AABB with (min, max) coordinates
333 """
334 # get the exact bounds of each entity
335 # some entities (aka 3- point Arc) have bounds that can't
336 # be generated from just bound box of vertices
338 points = np.array(
339 [e.bounds(self.vertices) for e in self.entities], dtype=np.float64
340 )
342 # flatten bound extrema into (n, dimension) array
343 points = points.reshape((-1, self.vertices.shape[1]))
344 # get the max and min of all bounds
345 return np.array([points.min(axis=0), points.max(axis=0)], dtype=np.float64)
347 @cache_decorator
348 def centroid(self) -> NDArray[float64]:
349 """
350 Return the centroid of axis aligned bounding box enclosing
351 all entities of the path object.
353 Returns
354 -----------
355 centroid : (d,) float
356 Approximate centroid of the path
357 """
358 return self.bounds.mean(axis=0)
360 @property
361 def extents(self) -> NDArray[float64]:
362 """
363 The size of the axis aligned bounding box.
365 Returns
366 ---------
367 extents : (dimension,) float
368 Edge length of AABB
369 """
370 return np.ptp(self.bounds, axis=0)
372 def convert_units(self, desired: str, guess: bool = False):
373 """
374 Convert the units of the current drawing in place.
376 Parameters
377 -----------
378 desired : str
379 Unit system to convert to
380 guess : bool
381 If True will attempt to guess units
382 """
383 units._convert_units(self, desired=desired, guess=guess)
385 def explode(self):
386 """
387 Turn every multi- segment entity into single segment
388 entities in- place.
389 """
390 new_entities = []
391 for entity in self.entities:
392 # explode into multiple entities
393 new_entities.extend(entity.explode())
394 # avoid setter and assign new entities
395 self._entities = np.array(new_entities)
396 # explicitly clear cache
397 self._cache.clear()
399 def fill_gaps(self, distance=0.025):
400 """
401 Find vertices without degree 2 and try to connect to
402 other vertices. Operations are done in-place.
404 Parameters
405 ----------
406 distance : float
407 Connect vertices up to this distance
408 """
409 repair.fill_gaps(self, distance=distance)
411 @property
412 def is_closed(self):
413 """
414 Are all entities connected to other entities.
416 Returns
417 -----------
418 closed : bool
419 Every entity is connected at its ends
420 """
421 closed = all(i == 2 for i in dict(self.vertex_graph.degree()).values())
423 return closed
425 @property
426 def is_empty(self):
427 """
428 Are any entities defined for the current path.
430 Returns
431 ----------
432 empty : bool
433 True if no entities are defined
434 """
435 return len(self.entities) == 0
437 @cache_decorator
438 def vertex_graph(self):
439 """
440 Return a networkx.Graph object for the entity connectivity
442 graph : networkx.Graph
443 Holds vertex indexes
444 """
445 graph, _closed = traversal.vertex_graph(self.entities)
446 return graph
448 @cache_decorator
449 def vertex_nodes(self):
450 """
451 Get a list of which vertex indices are nodes,
452 which are either endpoints or points where the
453 entity makes a direction change.
455 Returns
456 --------------
457 nodes : (n, 2) int
458 Indexes of self.vertices which are nodes
459 """
460 nodes = np.vstack([e.nodes for e in self.entities])
461 return nodes
463 def apply_transform(self, transform: ArrayLike) -> Self:
464 """
465 Apply a transformation matrix to the current path in- place
467 Parameters
468 -----------
469 transform : (d+1, d+1) float
470 Homogeneous transformations for vertices
471 """
472 dimension = self.vertices.shape[1]
473 transform = np.asanyarray(transform, dtype=np.float64)
475 if transform.shape != (dimension + 1, dimension + 1):
476 raise ValueError("transform is incorrect shape!")
477 elif np.abs(transform - np.eye(dimension + 1)).max() < 1e-8:
478 # if we've been passed an identity matrix do nothing
479 return self
481 # make sure cache is up to date
482 self._cache.verify()
483 # new cache to transfer items
484 cache = {}
485 # apply transform to discretized paths
486 if "discrete" in self._cache.cache:
487 cache["discrete"] = [
488 tf.transform_points(d, matrix=transform) for d in self.discrete
489 ]
491 # things we can just straight up copy
492 # as they are topological not geometric
493 for key in [
494 "root",
495 "paths",
496 "path_valid",
497 "dangling",
498 "vertex_graph",
499 "enclosure",
500 "enclosure_shell",
501 "enclosure_directed",
502 ]:
503 # if they're in cache save them from the purge
504 if key in self._cache.cache:
505 cache[key] = self._cache.cache[key]
507 # transform vertices in place
508 self.vertices = tf.transform_points(self.vertices, matrix=transform)
509 # explicitly clear the cache
510 self._cache.clear()
511 self._cache.id_set()
513 # populate the things we wangled
514 self._cache.cache.update(cache)
515 return self
517 def apply_layer(self, name: str) -> None:
518 """
519 Apply a layer name to every entity in the path.
521 Parameters
522 ------------
523 name : str
524 Apply layer name to every entity
525 """
526 for e in self.entities:
527 e.layer = name
529 def rezero(self):
530 """
531 Translate so that every vertex is positive in the current
532 mesh is positive.
534 Returns
535 -----------
536 matrix : (dimension + 1, dimension + 1) float
537 Homogeneous transformations that was applied
538 to the current Path object.
539 """
540 # transform to the lower left corner
541 matrix = tf.translation_matrix(-self.bounds[0])
542 # cleanly apply trransformation matrix
543 self.apply_transform(matrix)
545 return matrix
547 def merge_vertices(self, digits=None):
548 """
549 Merges vertices which are identical and replace references
550 by altering `self.entities` and `self.vertices`
552 Parameters
553 --------------
554 digits : None, or int
555 How many digits to consider when merging vertices
556 """
557 if len(self.vertices) == 0:
558 return
559 if digits is None:
560 digits = util.decimal_to_digits(tol.merge * self.scale, min_digits=1)
562 unique, inverse = grouping.unique_rows(self.vertices, digits=digits)
563 self.vertices = self.vertices[unique]
564 self.vertex_attributes = {
565 key: np.array(value)[unique] for key, value in self.vertex_attributes.items()
566 }
568 entities_ok = np.ones(len(self.entities), dtype=bool)
570 for index, entity in enumerate(self.entities):
571 # what kind of entity are we dealing with
572 kind = type(entity).__name__
574 # entities that don't need runs merged
575 # don't screw up control- point- knot relationship
576 if kind in "BSpline Bezier Text":
577 entity.points = inverse[entity.points]
578 continue
579 # if we merged duplicate vertices, the entity may
580 # have multiple references to the same vertex
581 points = grouping.merge_runs(inverse[entity.points])
582 # if there are three points and two are identical fix it
583 if kind == "Line":
584 if len(points) == 3 and points[0] == points[-1]:
585 points = points[:2]
586 elif len(points) < 2:
587 # lines need two or more vertices
588 entities_ok[index] = False
589 elif kind == "Arc" and len(points) != 3:
590 # three point arcs need three points
591 entities_ok[index] = False
593 # store points in entity
594 entity.points = points
596 # remove degenerate entities
597 self.entities = self.entities[entities_ok]
599 def replace_vertex_references(self, mask):
600 """
601 Replace the vertex index references in every entity.
603 Parameters
604 ------------
605 mask : (len(self.vertices), ) int
606 Contains new vertex indexes
608 Notes
609 ------------
610 entity.points in self.entities
611 Replaced by mask[entity.points]
612 """
613 for entity in self.entities:
614 entity.points = mask[entity.points]
616 def remove_entities(self, entity_ids):
617 """
618 Remove entities by index.
620 Parameters
621 -----------
622 entity_ids : (n,) int
623 Indexes of self.entities to remove
624 """
625 if len(entity_ids) == 0:
626 return
627 keep = np.ones(len(self.entities), dtype=bool)
628 keep[entity_ids] = False
629 self.entities = self.entities[keep]
631 def remove_invalid(self):
632 """
633 Remove entities which declare themselves invalid
635 Notes
636 ----------
637 self.entities: shortened
638 """
639 valid = np.array([i.is_valid for i in self.entities], dtype=bool)
640 self.entities = self.entities[valid]
642 def remove_duplicate_entities(self):
643 """
644 Remove entities that are duplicated
646 Notes
647 -------
648 self.entities: length same or shorter
649 """
650 entity_hashes = np.array([hash(i) for i in self.entities])
651 unique, _inverse = grouping.unique_rows(entity_hashes)
652 if len(unique) != len(self.entities):
653 self.entities = self.entities[unique]
655 @cache_decorator
656 def referenced_vertices(self):
657 """
658 Which vertices are referenced by an entity.
660 Returns
661 -----------
662 referenced_vertices: (n,) int, indexes of self.vertices
663 """
664 # no entities no reference
665 if len(self.entities) == 0:
666 return np.array([], dtype=np.int64)
667 return np.unique(
668 np.concatenate([e.points for e in self.entities]).astype(np.int64)
669 )
671 def remove_unreferenced_vertices(self):
672 """
673 Removes all vertices which aren't used by an entity.
675 Notes
676 ---------
677 self.vertices : reordered and shortened
678 self.entities : entity.points references updated
679 """
681 unique = self.referenced_vertices
683 mask = np.ones(len(self.vertices), dtype=np.int64) * -1
684 mask[unique] = np.arange(len(unique), dtype=np.int64)
686 self.replace_vertex_references(mask=mask)
687 self.vertices = self.vertices[unique]
689 @cache_decorator
690 def discrete(self) -> list[NDArray[float64]]:
691 """
692 A sequence of connected vertices in space, corresponding to
693 self.paths.
695 Returns
696 ---------
697 discrete : (len(self.paths),)
698 A sequence of (m*, dimension) float
699 """
700 # avoid cache hits in the loop
701 scale = self.scale
702 entities = self.entities
703 vertices = self.vertices
705 # discretize each path
706 return [
707 traversal.discretize_path(
708 entities=entities, vertices=vertices, path=path, scale=scale
709 )
710 for path in self.paths
711 ]
713 def export(self, file_obj=None, file_type=None, **kwargs):
714 """
715 Export the path to a file object or return data.
717 Parameters
718 ---------------
719 file_obj : None, str, or file object
720 File object or string to export to
721 file_type : None or str
722 Type of file: dxf, dict, svg
724 Returns
725 ---------------
726 exported : bytes or str
727 Exported as specified type
728 """
729 return export_path(self, file_type=file_type, file_obj=file_obj, **kwargs)
731 def to_dict(self) -> dict:
732 return self.export(file_type="dict")
734 def copy(self, layers: str | Iterable[str | None] | None = None):
735 """
736 Get a copy of the current mesh
738 Parameters
739 ------------
740 layers
741 If passed an iterable of layer names which will
742 only include those layers in the copy of the path.
744 Returns
745 ---------
746 copied : Path object
747 Copy of self
748 """
750 metadata = {}
751 # grab all the keys into a list so if something is added
752 # in another thread it probably doesn't stomp on our loop
753 for key in list(self.metadata.keys()):
754 try:
755 metadata[key] = deepcopy(self.metadata[key])
756 except RuntimeError:
757 # multiple threads
758 log.warning(f"key {key} changed during copy")
760 if layers is not None:
761 # get layers as a set for in-loop checks
762 if isinstance(layers, str):
763 # the `set` constructor would split in to char
764 layers = {layers}
765 else:
766 # a set of strings
767 layers = set(layers)
768 # cherry pick the entities we want
769 entities = [e for e in self.entities if e.layer in layers]
770 else:
771 entities = self.entities
773 # copy the core data
774 copied = type(self)(
775 entities=deepcopy(entities),
776 vertices=deepcopy(self.vertices),
777 metadata=metadata,
778 process=False,
779 )
781 # skip the cache wangling for a subset copy
782 if layers is not None:
783 return copied
785 cache = {}
786 # try to copy the cache over to the new object
787 try:
788 # save dict keys before doing slow iteration
789 keys = list(self._cache.cache.keys())
790 # run through each key and copy into new cache
791 for k in keys:
792 cache[k] = deepcopy(self._cache.cache[k])
793 except RuntimeError:
794 # if we have multiple threads this may error and is NBD
795 log.debug("unable to copy cache")
796 except BaseException:
797 # catch and log errors we weren't expecting
798 log.error("unable to copy cache", exc_info=True)
799 copied._cache.cache = cache
800 copied._cache.id_set()
802 return copied
804 def scene(self):
805 """
806 Get a scene object containing the current Path3D object.
808 Returns
809 --------
810 scene: trimesh.scene.Scene object containing current path
811 """
812 from ..scene import Scene
814 scene = Scene(self)
815 return scene
817 def __add__(self, other):
818 """
819 Concatenate two Path objects by appending vertices and
820 reindexing point references.
822 Parameters
823 -----------
824 other: Path object
826 Returns
827 -----------
828 concat: Path object, appended from self and other
829 """
830 concat = concatenate([self, other])
831 return concat
834class Path3D(Path):
835 """
836 Hold multiple vector curves (lines, arcs, splines, etc) in 3D.
837 """
839 def to_planar(self, *args, **kwargs):
840 """
841 DEPRECATED: replace `path.to_planar`->`path.to_2D), removal 1/1/2026
842 """
843 warnings.warn(
844 "DEPRECATED: replace `path.to_planar`->`path.to_2D), removal 1/1/2026",
845 category=DeprecationWarning,
846 stacklevel=2,
847 )
848 return self.to_2D(*args, **kwargs)
850 def to_2D(
851 self,
852 to_2D: ArrayLike | None = None,
853 normal: ArrayLike | None = None,
854 check: bool = True,
855 ) -> tuple["Path2D", NDArray[float64]]:
856 """
857 Check to see if current vectors are all coplanar.
859 If they are, return a Path2D and a transform which will
860 transform the 2D representation back into 3 dimensions
862 Parameters
863 -----------
864 to_2D : (4, 4) float
865 Homogeneous transformation matrix to apply,
866 if not passed a plane will be fitted to vertices.
867 normal : (3,) float or None
868 Normal of direction of plane to use.
869 check
870 Raise a ValueError if points aren't coplanar.
872 Returns
873 -----------
874 planar
875 Current path transformed onto plane
876 to_3D : (4, 4) float
877 Homeogenous transformations to move planar
878 back into the original 3D frame.
879 """
880 # which vertices are actually referenced
881 referenced = self.referenced_vertices
882 # if nothing is referenced return an empty path
883 if len(referenced) == 0:
884 return Path2D(), np.eye(4)
886 # support (n, 2) and (n, 3) vertices here
887 dim = self.vertices.shape[1]
889 # already flat
890 if dim == 2:
891 to_2D = np.eye(4)
892 elif dim != 3:
893 raise ValueError(f"vertices are `{dim}D != 2D | 3D`!")
895 # no explicit transform passed
896 if to_2D is None:
897 # fit a plane to our vertices
898 C, N = plane_fit(self.vertices[referenced])
899 # apply the normal sign hint
900 if normal is not None:
901 # make sure normal is a 3D vector
902 normal = np.array(normal, dtype=np.float64).reshape(3)
903 # apply the sign from the passed normal
904 N *= np.sign(np.dot(N, normal))
905 # create a transform from fit plane to XY
906 to_2D = plane_transform(origin=C, normal=N)
908 # make sure we've extracted a transform
909 to_2D = np.array(to_2D, dtype=np.float64)
910 if to_2D.shape != (4, 4):
911 raise ValueError("unable to create transform!")
913 if dim == 3:
914 # transform all vertices to 2D plane
915 flat = tf.transform_points(self.vertices, to_2D)
916 # Z values of vertices which are referenced
917 heights = flat[referenced][:, 2]
918 # points are not on a plane because Z varies
919 if np.ptp(heights) > tol.planar:
920 # since Z is inconsistent set height to zero
921 height = 0.0
922 if check:
923 raise ValueError("points are not flat!")
924 else:
925 # if the points were planar store the height
926 height = heights.mean()
927 elif dim == 2:
928 flat = self.vertices.copy()
929 height = 0.0
931 # the transform from 2D to 3D
932 to_3D = np.linalg.inv(to_2D)
934 # if the transform didn't move the path to
935 # exactly Z=0 adjust it so the returned transform does
936 if np.abs(height) > tol.planar:
937 # adjust to_3D transform by height
938 adjust = tf.translation_matrix([0, 0, height])
939 # apply the height adjustment to_3D
940 to_3D = np.dot(to_3D, adjust)
942 # copy metadata to new object
943 metadata = deepcopy(self.metadata)
944 # store transform we used to move it onto the plane
945 metadata["to_3D"] = to_3D
947 # create the Path2D with the same entities
948 # and XY values of vertices projected onto the plane
949 planar = Path2D(
950 entities=deepcopy(self.entities),
951 vertices=flat[:, :2],
952 metadata=metadata,
953 process=False,
954 )
956 return planar, to_3D
958 @cache_decorator
959 def identifier(self) -> NDArray[float64]:
960 """
961 Return a simple identifier for the 3D path.
962 """
963 return np.concatenate(
964 (comparison.identifier_simple(self.convex_hull), [self.length])
965 )
967 @cache_decorator
968 def convex_hull(self):
969 """
970 Return a convex hull of the 3D path.
972 Returns
973 --------
974 hull : trimesh.Trimesh
975 A mesh of the convex hull of the 3D path.
976 """
977 return convex.convex_hull(self.vertices[self.referenced_vertices])
979 def show(self, **kwargs):
980 """
981 Show the current Path3D object.
982 """
983 scene = self.scene()
984 return scene.show(**kwargs)
987class Path2D(Path):
988 """
989 Hold multiple vector curves (lines, arcs, splines, etc) in 3D.
990 """
992 def show(self, annotations=True):
993 """
994 Plot the current Path2D object using matplotlib.
995 """
996 if self.is_closed:
997 self.plot_discrete(show=True, annotations=annotations)
998 else:
999 self.plot_entities(show=True, annotations=annotations)
1001 def apply_obb(self):
1002 """
1003 Transform the current path so that its OBB is axis aligned
1004 and OBB center is at the origin.
1006 Returns
1007 -----------
1008 obb : (3, 3) float
1009 Homogeneous transformation matrix
1010 """
1011 matrix = self.obb
1012 self.apply_transform(matrix)
1013 return matrix
1015 def apply_scale(self, scale):
1016 """
1017 Apply a 2D scale to the current Path2D.
1019 Parameters
1020 -------------
1021 scale : float or (2,) float
1022 Scale to apply in-place.
1023 """
1024 matrix = np.eye(3)
1025 matrix[:2, :2] *= scale
1026 return self.apply_transform(matrix)
1028 @cache_decorator
1029 def obb(self):
1030 """
1031 Get a transform that centers and aligns the OBB of the
1032 referenced vertices with the XY axis.
1034 Returns
1035 -----------
1036 obb : (3, 3) float
1037 Homogeneous transformation matrix
1038 """
1039 matrix = bounds.oriented_bounds_2D(self.vertices[self.referenced_vertices])[0]
1040 return matrix
1042 @cache_decorator
1043 def convex_hull(self) -> "Path2D":
1044 """
1045 Return a convex hull of the 2D path.
1047 Returns
1048 --------
1049 hull
1050 A convex hull of included vertices from this path.
1051 """
1052 from scipy.spatial import ConvexHull
1054 from .exchange.misc import edges_to_path
1056 # include referenced vertices
1057 candidates = [self.vertices[self.referenced_vertices]]
1058 # include all points from discretized closed curves
1059 # this prevents arcs from being collapsed past the
1060 # discretization parameters set globally
1061 candidates.extend(self.discrete)
1062 candidates = np.vstack(candidates)
1064 # if there's only 2 points this is a zero-area hull
1065 if len(candidates) < 3:
1066 return Path2D()
1068 try:
1069 # calculate a 2D convex hull for our candidate vertices
1070 hull = ConvexHull(candidates)
1071 except BaseException:
1072 # this may raise if the geometry is colinear in
1073 # which case an empty path is correct
1074 log.debug("Failed to construct convex hull", exc_info=True)
1075 return Path2D()
1077 # map edges to throw away unused vertices
1078 # as `hull.points` includes all input points
1079 remap = np.arange(len(hull.points))
1080 remap[hull.vertices] = np.arange(len(hull.vertices))
1082 # get zero-indexed edges and only included vertices
1083 edges = remap[hull.simplices]
1084 vertices = hull.points[hull.vertices]
1086 return Path2D(**edges_to_path(edges=edges, vertices=vertices))
1088 def rasterize(
1089 self, pitch=None, origin=None, resolution=None, fill=True, width=None, **kwargs
1090 ):
1091 """
1092 Rasterize a Path2D object into a boolean image ("mode 1").
1094 Parameters
1095 ------------
1096 pitch : float or (2,) float
1097 Length(s) in model space of pixel edges
1098 origin : (2,) float
1099 Origin position in model space
1100 resolution : (2,) int
1101 Resolution in pixel space
1102 fill : bool
1103 If True will return closed regions as filled
1104 width : int
1105 If not None will draw outline this wide (pixels)
1107 Returns
1108 ------------
1109 raster : PIL.Image object, mode 1
1110 Rasterized version of closed regions.
1111 """
1112 image = raster.rasterize(
1113 self,
1114 pitch=pitch,
1115 origin=origin,
1116 resolution=resolution,
1117 fill=fill,
1118 width=width,
1119 )
1120 return image
1122 def sample(self, count, **kwargs):
1123 """
1124 Use rejection sampling to generate random points inside a
1125 polygon.
1127 Parameters
1128 -----------
1129 count : int
1130 Number of points to return
1131 If there are multiple bodies, there will
1132 be up to count * bodies points returned
1133 factor : float
1134 How many points to test per loop
1135 IE, count * factor
1136 max_iter : int,
1137 Maximum number of intersection loops
1138 to run, total points sampled is
1139 count * factor * max_iter
1141 Returns
1142 -----------
1143 hit : (n, 2) float
1144 Random points inside polygon
1145 """
1147 poly = self.polygons_full
1148 if len(poly) == 0:
1149 samples = np.array([])
1150 elif len(poly) == 1:
1151 samples = polygons.sample(poly[0], count=count, **kwargs)
1152 else:
1153 samples = util.vstack_empty(
1154 [polygons.sample(i, count=count, **kwargs) for i in poly]
1155 )
1157 return samples
1159 @property
1160 def body_count(self):
1161 """
1162 Returns a count of the number of unconnected polygons that
1163 may contain other curves but aren't contained themselves.
1165 Returns
1166 ---------
1167 body_count : int
1168 Number of unconnected independent polygons.
1169 """
1170 return len(self.root)
1172 def to_3D(self, transform=None):
1173 """
1174 Convert 2D path to 3D path on the XY plane.
1176 Parameters
1177 -------------
1178 transform : (4, 4) float
1179 If passed, will transform vertices.
1180 If not passed and 'to_3D' is in self.metadata
1181 that transform will be used.
1183 Returns
1184 -----------
1185 path_3D : Path3D
1186 3D version of current path
1187 """
1188 # if there is a stored 'to_3D' transform in metadata use it
1189 if transform is None and "to_3D" in self.metadata:
1190 transform = self.metadata["to_3D"]
1192 # copy vertices and stack with zeros from (n, 2) to (n, 3)
1193 vertices = np.column_stack(
1194 (deepcopy(self.vertices), np.zeros(len(self.vertices)))
1195 )
1196 if transform is not None:
1197 vertices = tf.transform_points(vertices, transform)
1198 # make sure everything is deep copied
1199 path_3D = Path3D(
1200 entities=deepcopy(self.entities),
1201 vertices=vertices,
1202 metadata=deepcopy(self.metadata),
1203 )
1204 return path_3D
1206 @cache_decorator
1207 def polygons_closed(self) -> NDArray:
1208 """
1209 Cycles in the vertex graph, as shapely.geometry.Polygons.
1210 These are polygon objects for every closed circuit, with no notion
1211 of whether a polygon is a hole or an area. Every polygon in this
1212 list will have an exterior, but NO interiors.
1214 Returns
1215 ---------
1216 polygons_closed : (n,) list of shapely.geometry.Polygon objects
1217 """
1218 # will attempt to recover invalid garbage geometry
1219 # and will be None if geometry is unrecoverable
1220 return polygons.paths_to_polygons(self.discrete)
1222 @cache_decorator
1223 def polygons_full(self) -> list:
1224 """
1225 A list of shapely.geometry.Polygon objects with interiors created
1226 by checking which closed polygons enclose which other polygons.
1228 Returns
1229 ---------
1230 full : (len(self.root),) shapely.geometry.Polygon
1231 Polygons containing interiors. An entry is `None` when the
1232 root curve's geometry could not be recovered (i.e. `None`
1233 in `polygons_closed`), preserving correspondence with
1234 `self.root`.
1235 """
1236 # pre- allocate the list to avoid indexing problems
1237 full = [None] * len(self.root)
1238 # store the graph to avoid cache thrashing
1239 enclosure = self.enclosure_directed
1240 # store closed polygons to avoid cache hits
1241 closed = self.polygons_closed
1243 # loop through root curves
1244 for i, root in enumerate(self.root):
1245 # `polygons_closed` may contain `None` for degenerate
1246 # geometry it could not recover: leave the entry as `None`
1247 # rather than raising an AttributeError on `None.exterior`
1248 if closed[root] is None:
1249 continue
1250 # a list of multiple Polygon objects that
1251 # are fully contained by the root curve
1252 children = [closed[child] for child in enclosure[root].keys()]
1253 # all polygons_closed are CCW, so for interiors reverse them
1254 holes = [np.array(p.exterior.coords)[::-1] for p in children if p is not None]
1255 # a single Polygon object
1256 shell = closed[root].exterior
1257 # create a polygon with interiors
1258 full[i] = polygons.repair_invalid(Polygon(shell=shell, holes=holes))
1260 return full
1262 @cache_decorator
1263 def area(self):
1264 """
1265 Return the area of the polygons interior.
1267 Returns
1268 ---------
1269 area : float
1270 Total area of polygons minus interiors
1271 """
1272 area = float(sum(i.area for i in self.polygons_full))
1273 return area
1275 def extrude(self, height, **kwargs):
1276 """
1277 Extrude the current 2D path into a 3D mesh.
1279 Parameters
1280 ----------
1281 height: float, how far to extrude the profile
1282 kwargs: passed directly to meshpy.triangle.build:
1283 triangle.build(mesh_info,
1284 verbose=False,
1285 refinement_func=None,
1286 attributes=False,
1287 volume_constraints=True,
1288 max_volume=None,
1289 allow_boundary_steiner=True,
1290 allow_volume_steiner=True,
1291 quality_meshing=True,
1292 generate_edges=None,
1293 generate_faces=False,
1294 min_angle=None)
1295 Returns
1296 --------
1297 mesh: trimesh object representing extruded polygon
1298 """
1299 from ..primitives import Extrusion
1301 result = [
1302 Extrusion(polygon=i, height=height, **kwargs) for i in self.polygons_full
1303 ]
1304 if len(result) == 1:
1305 return result[0]
1306 return result
1308 def triangulate(self, **kwargs):
1309 """
1310 Create a region- aware triangulation of the 2D path.
1312 Parameters
1313 -------------
1314 **kwargs : dict
1315 Passed to `trimesh.creation.triangulate_polygon`
1317 Returns
1318 -------------
1319 vertices : (n, 2) float
1320 2D vertices of triangulation
1321 faces : (n, 3) int
1322 Indexes of vertices for triangles
1323 """
1324 from ..creation import triangulate_polygon
1326 # append vertices and faces into sequence
1327 v_seq = []
1328 f_seq = []
1330 # loop through polygons with interiors
1331 for polygon in self.polygons_full:
1332 v, f = triangulate_polygon(polygon, **kwargs)
1333 v_seq.append(v)
1334 f_seq.append(f)
1336 return util.append_faces(v_seq, f_seq)
1338 def medial_axis(self, resolution=None, clip=None):
1339 """
1340 Find the approximate medial axis based
1341 on a voronoi diagram of evenly spaced points on the
1342 boundary of the polygon.
1344 Parameters
1345 ----------
1346 resolution : None or float
1347 Distance between each sample on the polygon boundary
1348 clip : None, or (2,) float
1349 Min, max number of samples
1351 Returns
1352 ----------
1353 medial : Path2D object
1354 Contains only medial axis of Path
1355 """
1356 if resolution is None:
1357 resolution = self.scale / 1000.0
1359 # convert the edges to Path2D kwargs
1360 from .exchange.misc import edges_to_path
1362 # edges and vertices
1363 edge_vert = [
1364 polygons.medial_axis(i, resolution, clip) for i in self.polygons_full
1365 ]
1366 # create a Path2D object for each region
1367 medials = [Path2D(**edges_to_path(edges=e, vertices=v)) for e, v in edge_vert]
1369 # get a single Path2D of medial axis
1370 medial = concatenate(medials)
1372 return medial
1374 def connected_paths(self, path_id, include_self=False):
1375 """
1376 Given an index of self.paths find other paths which
1377 overlap with that path.
1379 Parameters
1380 -----------
1381 path_id : int
1382 Index of self.paths
1383 include_self : bool
1384 Should the result include path_id or not
1386 Returns
1387 -----------
1388 path_ids : (n, ) int
1389 Indexes of self.paths that overlap input path_id
1390 """
1391 if len(self.root) == 1:
1392 path_ids = np.arange(len(self.polygons_closed))
1393 else:
1394 path_ids = list(nx.node_connected_component(self.enclosure, path_id))
1395 if include_self:
1396 return np.array(path_ids)
1397 return np.setdiff1d(path_ids, [path_id])
1399 def simplify(self, **kwargs):
1400 """
1401 Return a version of the current path with colinear segments
1402 merged, and circles entities replacing segmented circular paths.
1404 Returns
1405 ---------
1406 simplified : Path2D object
1407 """
1408 return simplify.simplify_basic(self, **kwargs)
1410 def simplify_spline(self, smooth=0.0002, verbose=False):
1411 """
1412 Convert paths into b-splines.
1414 Parameters
1415 -----------
1416 smooth : float
1417 How much the spline should smooth the curve
1418 verbose : bool
1419 Print detailed log messages
1421 Returns
1422 ------------
1423 simplified : Path2D
1424 Discrete curves replaced with splines
1425 """
1426 return simplify.simplify_spline(self, smooth=smooth, verbose=verbose)
1428 def split(self, **kwargs):
1429 """
1430 If the current Path2D consists of n 'root' curves,
1431 split them into a list of n Path2D objects
1433 Returns
1434 ----------
1435 split: (n,) list of Path2D objects
1436 Each connected region and interiors
1437 """
1438 return traversal.split(self)
1440 def plot_discrete(self, show=False, annotations=True):
1441 """
1442 Plot the closed curves of the path.
1443 """
1444 import matplotlib.pyplot as plt # noqa
1446 axis = plt.gca()
1447 axis.set_aspect("equal", "datalim")
1449 for i, points in enumerate(self.discrete):
1450 color = ["g", "k"][i in self.root]
1451 axis.plot(*points.T, color=color)
1453 if annotations:
1454 for e in self.entities:
1455 if not hasattr(e, "plot"):
1456 continue
1457 e.plot(self.vertices)
1459 if show:
1460 plt.show()
1461 return axis
1463 def plot_entities(self, show=False, annotations=True, color=None):
1464 """
1465 Plot the entities of the path with no notion of topology.
1467 Parameters
1468 ------------
1469 show : bool
1470 Open a window immediately or not
1471 annotations : bool
1472 Call an entities custom plot function.
1473 color : str
1474 Override entity colors and make them all this color.
1475 """
1476 import matplotlib.pyplot as plt # noqa
1478 # keep plot axis scaled the same
1479 axis = plt.gca()
1480 axis.set_aspect("equal", "datalim")
1481 # hardcode a format for each entity type
1482 eformat = {
1483 "Line0": {"color": "g", "linewidth": 1},
1484 "Line1": {"color": "y", "linewidth": 1},
1485 "Arc0": {"color": "r", "linewidth": 1},
1486 "Arc1": {"color": "b", "linewidth": 1},
1487 "Bezier0": {"color": "k", "linewidth": 1},
1488 "Bezier1": {"color": "k", "linewidth": 1},
1489 "BSpline0": {"color": "m", "linewidth": 1},
1490 "BSpline1": {"color": "m", "linewidth": 1},
1491 }
1492 for entity in self.entities:
1493 # if the entity has it's own plot method use it
1494 if annotations and hasattr(entity, "plot"):
1495 entity.plot(self.vertices)
1496 continue
1497 # otherwise plot the discrete curve
1498 discrete = entity.discrete(self.vertices)
1499 # a unique key for entities
1500 e_key = entity.__class__.__name__ + str(int(entity.closed))
1502 fmt = eformat[e_key].copy()
1503 if color is not None:
1504 # passed color will override other options
1505 fmt["color"] = color
1506 elif hasattr(entity, "color"):
1507 # if entity has specified color use it
1508 fmt["color"] = entity.color
1509 axis.plot(*discrete.T, **fmt)
1510 if show:
1511 plt.show()
1513 @property
1514 def identifier(self):
1515 """
1516 A unique identifier for the path.
1518 Returns
1519 ---------
1520 identifier : (5,) float
1521 Unique identifier
1522 """
1523 hasher = polygons.identifier
1524 target = self.polygons_full
1525 if len(target) == 1:
1526 return hasher(self.polygons_full[0])
1527 elif len(target) == 0:
1528 return np.zeros(5)
1529 return np.sum([hasher(p) for p in target], axis=0)
1531 @property
1532 def path_valid(self):
1533 """
1534 Returns
1535 ----------
1536 path_valid : (n,) bool
1537 Indexes of self.paths self.polygons_closed
1538 which are valid polygons.
1539 """
1540 return np.array([i is not None for i in self.polygons_closed], dtype=bool)
1542 @cache_decorator
1543 def root(self) -> NDArray[np.int64]:
1544 """
1545 Which indexes of self.paths/self.polygons_closed
1546 are root curves, also known as 'shell' or 'exterior.
1548 Returns
1549 ---------
1550 root : (n,) int
1551 List of indexes
1552 """
1553 populate = self.enclosure_directed # NOQA
1554 return self._cache["root"]
1556 @cache_decorator
1557 def enclosure(self):
1558 """
1559 Undirected graph object of polygon enclosure.
1561 Returns
1562 -----------
1563 enclosure : networkx.Graph
1564 Enclosure graph of self.polygons by index.
1565 """
1566 with self._cache:
1567 undirected = self.enclosure_directed.to_undirected()
1568 return undirected
1570 @cache_decorator
1571 def enclosure_directed(self):
1572 """
1573 Directed graph of polygon enclosure.
1575 Returns
1576 ----------
1577 enclosure_directed : networkx.DiGraph
1578 Directed graph: child nodes are fully
1579 contained by their parent node.
1580 """
1581 root, enclosure = polygons.enclosure_tree(self.polygons_closed)
1582 self._cache["root"] = root
1583 return enclosure
1585 @cache_decorator
1586 def enclosure_shell(self):
1587 """
1588 A dictionary of path indexes which are 'shell' paths, and values
1589 of 'hole' paths.
1591 Returns
1592 ----------
1593 corresponding : dict
1594 {index of self.paths of shell : [indexes of holes]}
1595 """
1596 pairs = [(r, self.connected_paths(r, include_self=False)) for r in self.root]
1597 # OrderedDict to maintain corresponding order
1598 return collections.OrderedDict(pairs)