Coverage for trimesh/path/path.py: 87%
506 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"""
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 | None | Iterable[str | 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
1232 """
1233 # pre- allocate the list to avoid indexing problems
1234 full = [None] * len(self.root)
1235 # store the graph to avoid cache thrashing
1236 enclosure = self.enclosure_directed
1237 # store closed polygons to avoid cache hits
1238 closed = self.polygons_closed
1240 # loop through root curves
1241 for i, root in enumerate(self.root):
1242 # a list of multiple Polygon objects that
1243 # are fully contained by the root curve
1244 children = [closed[child] for child in enclosure[root].keys()]
1245 # all polygons_closed are CCW, so for interiors reverse them
1246 holes = [np.array(p.exterior.coords)[::-1] for p in children]
1247 # a single Polygon object
1248 shell = closed[root].exterior
1249 # create a polygon with interiors
1250 full[i] = polygons.repair_invalid(Polygon(shell=shell, holes=holes))
1252 return full
1254 @cache_decorator
1255 def area(self):
1256 """
1257 Return the area of the polygons interior.
1259 Returns
1260 ---------
1261 area : float
1262 Total area of polygons minus interiors
1263 """
1264 area = float(sum(i.area for i in self.polygons_full))
1265 return area
1267 def extrude(self, height, **kwargs):
1268 """
1269 Extrude the current 2D path into a 3D mesh.
1271 Parameters
1272 ----------
1273 height: float, how far to extrude the profile
1274 kwargs: passed directly to meshpy.triangle.build:
1275 triangle.build(mesh_info,
1276 verbose=False,
1277 refinement_func=None,
1278 attributes=False,
1279 volume_constraints=True,
1280 max_volume=None,
1281 allow_boundary_steiner=True,
1282 allow_volume_steiner=True,
1283 quality_meshing=True,
1284 generate_edges=None,
1285 generate_faces=False,
1286 min_angle=None)
1287 Returns
1288 --------
1289 mesh: trimesh object representing extruded polygon
1290 """
1291 from ..primitives import Extrusion
1293 result = [
1294 Extrusion(polygon=i, height=height, **kwargs) for i in self.polygons_full
1295 ]
1296 if len(result) == 1:
1297 return result[0]
1298 return result
1300 def triangulate(self, **kwargs):
1301 """
1302 Create a region- aware triangulation of the 2D path.
1304 Parameters
1305 -------------
1306 **kwargs : dict
1307 Passed to `trimesh.creation.triangulate_polygon`
1309 Returns
1310 -------------
1311 vertices : (n, 2) float
1312 2D vertices of triangulation
1313 faces : (n, 3) int
1314 Indexes of vertices for triangles
1315 """
1316 from ..creation import triangulate_polygon
1318 # append vertices and faces into sequence
1319 v_seq = []
1320 f_seq = []
1322 # loop through polygons with interiors
1323 for polygon in self.polygons_full:
1324 v, f = triangulate_polygon(polygon, **kwargs)
1325 v_seq.append(v)
1326 f_seq.append(f)
1328 return util.append_faces(v_seq, f_seq)
1330 def medial_axis(self, resolution=None, clip=None):
1331 """
1332 Find the approximate medial axis based
1333 on a voronoi diagram of evenly spaced points on the
1334 boundary of the polygon.
1336 Parameters
1337 ----------
1338 resolution : None or float
1339 Distance between each sample on the polygon boundary
1340 clip : None, or (2,) float
1341 Min, max number of samples
1343 Returns
1344 ----------
1345 medial : Path2D object
1346 Contains only medial axis of Path
1347 """
1348 if resolution is None:
1349 resolution = self.scale / 1000.0
1351 # convert the edges to Path2D kwargs
1352 from .exchange.misc import edges_to_path
1354 # edges and vertices
1355 edge_vert = [
1356 polygons.medial_axis(i, resolution, clip) for i in self.polygons_full
1357 ]
1358 # create a Path2D object for each region
1359 medials = [Path2D(**edges_to_path(edges=e, vertices=v)) for e, v in edge_vert]
1361 # get a single Path2D of medial axis
1362 medial = concatenate(medials)
1364 return medial
1366 def connected_paths(self, path_id, include_self=False):
1367 """
1368 Given an index of self.paths find other paths which
1369 overlap with that path.
1371 Parameters
1372 -----------
1373 path_id : int
1374 Index of self.paths
1375 include_self : bool
1376 Should the result include path_id or not
1378 Returns
1379 -----------
1380 path_ids : (n, ) int
1381 Indexes of self.paths that overlap input path_id
1382 """
1383 if len(self.root) == 1:
1384 path_ids = np.arange(len(self.polygons_closed))
1385 else:
1386 path_ids = list(nx.node_connected_component(self.enclosure, path_id))
1387 if include_self:
1388 return np.array(path_ids)
1389 return np.setdiff1d(path_ids, [path_id])
1391 def simplify(self, **kwargs):
1392 """
1393 Return a version of the current path with colinear segments
1394 merged, and circles entities replacing segmented circular paths.
1396 Returns
1397 ---------
1398 simplified : Path2D object
1399 """
1400 return simplify.simplify_basic(self, **kwargs)
1402 def simplify_spline(self, smooth=0.0002, verbose=False):
1403 """
1404 Convert paths into b-splines.
1406 Parameters
1407 -----------
1408 smooth : float
1409 How much the spline should smooth the curve
1410 verbose : bool
1411 Print detailed log messages
1413 Returns
1414 ------------
1415 simplified : Path2D
1416 Discrete curves replaced with splines
1417 """
1418 return simplify.simplify_spline(self, smooth=smooth, verbose=verbose)
1420 def split(self, **kwargs):
1421 """
1422 If the current Path2D consists of n 'root' curves,
1423 split them into a list of n Path2D objects
1425 Returns
1426 ----------
1427 split: (n,) list of Path2D objects
1428 Each connected region and interiors
1429 """
1430 return traversal.split(self)
1432 def plot_discrete(self, show=False, annotations=True):
1433 """
1434 Plot the closed curves of the path.
1435 """
1436 import matplotlib.pyplot as plt # noqa
1438 axis = plt.gca()
1439 axis.set_aspect("equal", "datalim")
1441 for i, points in enumerate(self.discrete):
1442 color = ["g", "k"][i in self.root]
1443 axis.plot(*points.T, color=color)
1445 if annotations:
1446 for e in self.entities:
1447 if not hasattr(e, "plot"):
1448 continue
1449 e.plot(self.vertices)
1451 if show:
1452 plt.show()
1453 return axis
1455 def plot_entities(self, show=False, annotations=True, color=None):
1456 """
1457 Plot the entities of the path with no notion of topology.
1459 Parameters
1460 ------------
1461 show : bool
1462 Open a window immediately or not
1463 annotations : bool
1464 Call an entities custom plot function.
1465 color : str
1466 Override entity colors and make them all this color.
1467 """
1468 import matplotlib.pyplot as plt # noqa
1470 # keep plot axis scaled the same
1471 axis = plt.gca()
1472 axis.set_aspect("equal", "datalim")
1473 # hardcode a format for each entity type
1474 eformat = {
1475 "Line0": {"color": "g", "linewidth": 1},
1476 "Line1": {"color": "y", "linewidth": 1},
1477 "Arc0": {"color": "r", "linewidth": 1},
1478 "Arc1": {"color": "b", "linewidth": 1},
1479 "Bezier0": {"color": "k", "linewidth": 1},
1480 "Bezier1": {"color": "k", "linewidth": 1},
1481 "BSpline0": {"color": "m", "linewidth": 1},
1482 "BSpline1": {"color": "m", "linewidth": 1},
1483 }
1484 for entity in self.entities:
1485 # if the entity has it's own plot method use it
1486 if annotations and hasattr(entity, "plot"):
1487 entity.plot(self.vertices)
1488 continue
1489 # otherwise plot the discrete curve
1490 discrete = entity.discrete(self.vertices)
1491 # a unique key for entities
1492 e_key = entity.__class__.__name__ + str(int(entity.closed))
1494 fmt = eformat[e_key].copy()
1495 if color is not None:
1496 # passed color will override other options
1497 fmt["color"] = color
1498 elif hasattr(entity, "color"):
1499 # if entity has specified color use it
1500 fmt["color"] = entity.color
1501 axis.plot(*discrete.T, **fmt)
1502 if show:
1503 plt.show()
1505 @property
1506 def identifier(self):
1507 """
1508 A unique identifier for the path.
1510 Returns
1511 ---------
1512 identifier : (5,) float
1513 Unique identifier
1514 """
1515 hasher = polygons.identifier
1516 target = self.polygons_full
1517 if len(target) == 1:
1518 return hasher(self.polygons_full[0])
1519 elif len(target) == 0:
1520 return np.zeros(5)
1521 return np.sum([hasher(p) for p in target], axis=0)
1523 @property
1524 def path_valid(self):
1525 """
1526 Returns
1527 ----------
1528 path_valid : (n,) bool
1529 Indexes of self.paths self.polygons_closed
1530 which are valid polygons.
1531 """
1532 return np.array([i is not None for i in self.polygons_closed], dtype=bool)
1534 @cache_decorator
1535 def root(self) -> NDArray[np.int64]:
1536 """
1537 Which indexes of self.paths/self.polygons_closed
1538 are root curves, also known as 'shell' or 'exterior.
1540 Returns
1541 ---------
1542 root : (n,) int
1543 List of indexes
1544 """
1545 populate = self.enclosure_directed # NOQA
1546 return self._cache["root"]
1548 @cache_decorator
1549 def enclosure(self):
1550 """
1551 Undirected graph object of polygon enclosure.
1553 Returns
1554 -----------
1555 enclosure : networkx.Graph
1556 Enclosure graph of self.polygons by index.
1557 """
1558 with self._cache:
1559 undirected = self.enclosure_directed.to_undirected()
1560 return undirected
1562 @cache_decorator
1563 def enclosure_directed(self):
1564 """
1565 Directed graph of polygon enclosure.
1567 Returns
1568 ----------
1569 enclosure_directed : networkx.DiGraph
1570 Directed graph: child nodes are fully
1571 contained by their parent node.
1572 """
1573 root, enclosure = polygons.enclosure_tree(self.polygons_closed)
1574 self._cache["root"] = root
1575 return enclosure
1577 @cache_decorator
1578 def enclosure_shell(self):
1579 """
1580 A dictionary of path indexes which are 'shell' paths, and values
1581 of 'hole' paths.
1583 Returns
1584 ----------
1585 corresponding : dict
1586 {index of self.paths of shell : [indexes of holes]}
1587 """
1588 pairs = [(r, self.connected_paths(r, include_self=False)) for r in self.root]
1589 # OrderedDict to maintain corresponding order
1590 return collections.OrderedDict(pairs)