Coverage for trimesh/primitives.py: 94%
368 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-08-31 18:21 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-08-31 18:21 +0000
1"""
2primitives.py
3----------------
5Subclasses of Trimesh objects that are parameterized as primitives.
7Useful because you can move boxes and spheres around
8and then use trimesh operations on them at any point.
9"""
11import abc
13import numpy as np
15from . import creation, inertia, sample, triangles, util
16from . import transformations as tf
17from .base import Trimesh
18from .caching import cache_decorator
19from .constants import log, tol
20from .typed import ArrayLike, Integer, Number, Seed
22# immutable identity matrix for checks
23_IDENTITY = np.eye(4)
24_IDENTITY.flags.writeable = False
27class Primitive(Trimesh):
28 """
29 Geometric Primitives which are a subclass of Trimesh.
30 Mesh is generated lazily when vertices or faces are requested.
31 """
33 # ignore superclass copy directives
34 __copy__ = None
35 __deepcopy__ = None
37 def __init__(self):
38 # run the Trimesh constructor with no arguments
39 super().__init__()
41 # remove any data
42 self._data.clear()
43 self._validate = False
45 # make sure any cached numpy arrays have
46 # set `array.flags.writable = False`
47 self._cache.force_immutable = True
49 def __repr__(self):
50 return f"<trimesh.primitives.{type(self).__name__}>"
52 @property
53 def faces(self):
54 stored = self._cache["faces"]
55 if util.is_shape(stored, (-1, 3)):
56 return stored
57 self._create_mesh()
58 return self._cache["faces"]
60 @faces.setter
61 def faces(self, values):
62 if values is not None:
63 raise ValueError("primitive faces are immutable: not setting!")
65 @property
66 def vertices(self):
67 stored = self._cache["vertices"]
68 if util.is_shape(stored, (-1, 3)):
69 return stored
71 self._create_mesh()
72 return self._cache["vertices"]
74 @vertices.setter
75 def vertices(self, values):
76 if values is not None:
77 raise ValueError("primitive vertices are immutable: not setting!")
79 @property
80 def face_normals(self):
81 # if the mesh hasn't been created yet do that
82 # before checking to see if the mesh creation
83 # already populated the face normals
84 if "vertices" not in self._cache:
85 self._create_mesh()
87 # we need to avoid the logic in the superclass that
88 # is specific to the data model prioritizing faces
89 stored = self._cache["face_normals"]
90 if util.is_shape(stored, (-1, 3)):
91 return stored
93 # if the creation did not populate normals we have to do it
94 # just calculate if not stored
95 unit, valid = triangles.normals(self.triangles)
96 normals = np.zeros((len(valid), 3))
97 normals[valid] = unit
98 # store and return
99 self._cache["face_normals"] = normals
100 return normals
102 @face_normals.setter
103 def face_normals(self, values):
104 if values is not None:
105 log.warning("Primitive face normals are immutable!")
107 @property
108 def transform(self):
109 """
110 The transform of the Primitive object.
112 Returns
113 -------------
114 transform : (4, 4) float
115 Homogeneous transformation matrix
116 """
117 return self.primitive.transform
119 @abc.abstractmethod
120 def to_dict(self):
121 """
122 Should be implemented by each primitive.
123 """
124 raise NotImplementedError()
126 def copy(self, include_visual=True, **kwargs):
127 """
128 Return a copy of the Primitive object.
130 Returns
131 -------------
132 copied : object
133 Copy of current primitive
134 """
135 # get the constructor arguments
136 kwargs.update(self.to_dict())
137 # remove the type indicator, i.e. `Cylinder`
138 kwargs.pop("kind")
139 # create a new object with kwargs
140 primitive_copy = type(self)(**kwargs)
142 if include_visual:
143 # copy visual information
144 primitive_copy.visual = self.visual.copy()
146 # copy metadata
147 primitive_copy.metadata = self.metadata.copy()
149 for k, v in self._data.data.items():
150 if k not in primitive_copy._data:
151 primitive_copy._data[k] = v
153 return primitive_copy
155 def to_mesh(self, **kwargs):
156 """
157 Return a copy of the Primitive object as a Trimesh.
159 Parameters
160 -----------
161 kwargs : dict
162 Passed to the Trimesh object constructor.
164 Returns
165 ------------
166 mesh : trimesh.Trimesh
167 Tessellated version of the primitive.
168 """
169 result = Trimesh(
170 vertices=self.vertices.copy(),
171 faces=self.faces.copy(),
172 face_normals=self.face_normals.copy(),
173 process=kwargs.pop("process", False),
174 **kwargs,
175 )
176 return result
178 def apply_transform(self, matrix):
179 """
180 Apply a transform to the current primitive by
181 applying a new transform on top of existing
182 `self.primitive.transform`. If the matrix
183 contains scaling it will change parameters
184 like `radius` or `height` automatically.
186 Parameters
187 ------------
188 matrix: (4, 4) float
189 Homogeneous transformation
190 """
191 matrix = np.asanyarray(matrix, order="C", dtype=np.float64)
192 if matrix.shape != (4, 4):
193 raise ValueError("matrix must be `(4, 4)`!")
194 if util.allclose(matrix, _IDENTITY, 1e-8):
195 # identity matrix is a no-op
196 return self
198 prim = self.primitive
199 # copy the current transform
200 current = prim.transform.copy()
201 # see if matrix has scaling from the matrix
202 scale = np.linalg.det(matrix[:3, :3]) ** (1.0 / 3.0)
204 # the objects we handle re-scaling for
205 # note that `Extrusion` is NOT supported
206 kinds = (Box, Cylinder, Capsule, Sphere)
207 if isinstance(self, kinds) and abs(scale - 1.0) > 1e-8:
208 # scale the primitive attributes
209 if hasattr(prim, "height"):
210 prim.height *= scale
211 if hasattr(prim, "radius"):
212 prim.radius *= scale
213 if hasattr(prim, "extents"):
214 prim.extents *= scale
215 # scale the translation of the current matrix
216 current[:3, 3] *= scale
217 # apply new matrix, rescale, translate, current
218 updated = util.multi_dot([matrix, tf.scale_matrix(1.0 / scale), current])
219 else:
220 # without scaling just multiply
221 updated = np.dot(matrix, current)
223 # make sure matrix is a rigid transform
224 if not tf.is_rigid(updated):
225 raise ValueError("Couldn't produce rigid transform!")
227 # apply the new matrix
228 self.primitive.transform = updated
230 return self
232 def _create_mesh(self):
233 raise ValueError("Primitive doesn't define mesh creation!")
236class PrimitiveAttributes:
237 """
238 Hold the mutable data which defines a primitive.
239 """
241 def __init__(self, parent, defaults, kwargs, mutable=True):
242 """
243 Hold the attributes for a Primitive.
245 Parameters
246 ------------
247 parent : Primitive
248 Parent object reference.
249 defaults : dict
250 The default values for this primitive type.
251 kwargs : dict
252 User-passed values, i.e. {'radius': 10.0}
253 """
254 # store actual data in parent object
255 self._data = parent._data
256 # default values define the keys
257 self._defaults = defaults
258 # store a reference to the parent ubject
259 self._parent = parent
260 # start with a copy of all default objects
261 self._data.update(defaults)
262 # store whether this data is mutable after creation
263 self._mutable = mutable
264 # assign the keys passed by the user only if
265 # they are a property of this primitive
266 for key, default in defaults.items():
267 value = kwargs.get(key, None)
268 if value is not None:
269 # convert passed data into type of defaults
270 self._data[key] = util.convert_like(value, default)
271 # make sure stored values are immutable after setting
272 if not self._mutable:
273 self._data.mutable = False
275 @property
276 def __doc__(self):
277 # this is generated dynamically as the format
278 # operation can be surprisingly slow and most
279 # people never call it
280 import pprint
282 doc = (
283 "Store the attributes of a {name} object.\n\n"
284 + "When these values are changed, the mesh geometry will \n"
285 + "automatically be updated to reflect the new values.\n\n"
286 + "Available properties and their default values are:\n {defaults}"
287 + "\n\nExample\n---------------\n"
288 + "p = trimesh.primitives.{name}()\n"
289 + "p.primitive.radius = 10\n"
290 + "\n"
291 ).format(
292 name=self._parent.__class__.__name__,
293 defaults=pprint.pformat(self._defaults, width=-1)[1:-1],
294 )
295 return doc
297 def __getattr__(self, key):
298 if key.startswith("_"):
299 return super().__getattr__(key)
300 elif key == "center":
301 # this whole __getattr__ is a little hacky
302 return self._data["transform"][:3, 3]
303 elif key in self._defaults:
304 return util.convert_like(self._data[key], self._defaults[key])
305 raise AttributeError(f"primitive object has no attribute '{key}' ")
307 def __setattr__(self, key, value):
308 if key.startswith("_"):
309 return super().__setattr__(key, value)
310 elif key == "center":
311 value = np.array(value, dtype=np.float64)
312 transform = np.eye(4)
313 transform[:3, 3] = value
314 self._data["transform"] = transform
315 return
316 elif key in self._defaults:
317 if self._mutable:
318 self._data[key] = util.convert_like(value, self._defaults[key])
319 else:
320 raise ValueError(
321 "Primitive is configured as immutable! Cannot set attribute!"
322 )
323 else:
324 keys = list(self._defaults.keys())
325 raise ValueError(f"Only default attributes {keys} can be set!")
327 def __dir__(self):
328 result = sorted(dir(type(self)) + list(self._defaults.keys()))
329 return result
332class Cylinder(Primitive):
333 def __init__(self, radius=1.0, height=1.0, transform=None, sections=32, mutable=True):
334 """
335 Create a Cylinder Primitive, a subclass of Trimesh.
337 Parameters
338 -------------
339 radius : float
340 Radius of cylinder
341 height : float
342 Height of cylinder
343 transform : (4, 4) float
344 Homogeneous transformation matrix
345 sections : int
346 Number of facets in circle.
347 mutable : bool
348 Are extents and transform mutable after creation.
349 """
350 super().__init__()
352 defaults = {"height": 10.0, "radius": 1.0, "transform": np.eye(4), "sections": 32}
353 self.primitive = PrimitiveAttributes(
354 self,
355 defaults=defaults,
356 kwargs={
357 "height": height,
358 "radius": radius,
359 "transform": transform,
360 "sections": sections,
361 },
362 mutable=mutable,
363 )
365 @cache_decorator
366 def volume(self):
367 """
368 The analytic volume of the cylinder primitive.
370 Returns
371 ---------
372 volume : float
373 Volume of the cylinder
374 """
375 return (np.pi * self.primitive.radius**2) * self.primitive.height
377 @cache_decorator
378 def area(self) -> float:
379 """
380 The analytical area of the cylinder primitive
381 """
382 # circumfrence * height + end-cap-area
383 radius, height = self.primitive.radius, self.primitive.height
384 return (np.pi * 2 * radius * height) + (2 * np.pi * radius**2)
386 @cache_decorator
387 def moment_inertia(self):
388 """
389 The analytic inertia tensor of the cylinder primitive.
391 Returns
392 ----------
393 tensor: (3, 3) float
394 3D inertia tensor
395 """
397 tensor = inertia.cylinder_inertia(
398 mass=self.volume,
399 radius=self.primitive.radius,
400 height=self.primitive.height,
401 transform=self.primitive.transform,
402 )
403 return tensor
405 @cache_decorator
406 def direction(self):
407 """
408 The direction of the cylinder's axis.
410 Returns
411 --------
412 axis: (3,) float, vector along the cylinder axis
413 """
414 axis = np.dot(self.primitive.transform, [0, 0, 1, 0])[:3]
415 return axis
417 @property
418 def segment(self):
419 """
420 A line segment which if inflated by cylinder radius
421 would represent the cylinder primitive.
423 Returns
424 -------------
425 segment : (2, 3) float
426 Points representing a single line segment
427 """
428 # half the height
429 half = self.primitive.height / 2.0
430 # apply the transform to the Z- aligned segment
431 points = np.dot(
432 self.primitive.transform, np.transpose([[0, 0, -half, 1], [0, 0, half, 1]])
433 ).T[:, :3]
434 return points
436 def to_dict(self):
437 """
438 Get a copy of the current Cylinder primitive as
439 a JSON-serializable dict that matches the schema
440 in `trimesh/resources/schema/cylinder.schema.json`
442 Returns
443 ----------
444 as_dict : dict
445 Serializable data for this primitive.
446 """
447 return {
448 "kind": "cylinder",
449 "transform": self.primitive.transform.tolist(),
450 "radius": float(self.primitive.radius),
451 "height": float(self.primitive.height),
452 }
454 def buffer(self, distance):
455 """
456 Return a cylinder primitive which covers the source
457 cylinder by distance: radius is inflated by distance
458 height by twice the distance.
460 Parameters
461 ------------
462 distance : float
463 Distance to inflate cylinder radius and height
465 Returns
466 -------------
467 buffered : Cylinder
468 Cylinder primitive inflated by distance
469 """
470 distance = float(distance)
471 buffered = Cylinder(
472 height=self.primitive.height + distance * 2,
473 radius=self.primitive.radius + distance,
474 transform=self.primitive.transform.copy(),
475 )
476 return buffered
478 def _create_mesh(self):
479 log.debug("creating mesh for Cylinder primitive")
480 mesh = creation.cylinder(
481 radius=self.primitive.radius,
482 height=self.primitive.height,
483 sections=self.primitive.sections,
484 transform=self.primitive.transform,
485 )
487 self._cache["vertices"] = mesh.vertices
488 self._cache["faces"] = mesh.faces
489 self._cache["face_normals"] = mesh.face_normals
492class Capsule(Primitive):
493 def __init__(
494 self, radius=1.0, height=10.0, transform=None, sections=32, mutable=True
495 ):
496 """
497 Create a Capsule Primitive, a subclass of Trimesh.
499 Parameters
500 ----------
501 radius : float
502 Radius of cylinder
503 height : float
504 Height of cylinder
505 transform : (4, 4) float
506 Transformation matrix
507 sections : int
508 Number of facets in circle
509 mutable : bool
510 Are extents and transform mutable after creation.
511 """
512 super().__init__()
514 defaults = {"height": 1.0, "radius": 1.0, "transform": np.eye(4), "sections": 32}
515 self.primitive = PrimitiveAttributes(
516 self,
517 defaults=defaults,
518 kwargs={
519 "height": height,
520 "radius": radius,
521 "transform": transform,
522 "sections": sections,
523 },
524 mutable=mutable,
525 )
527 @property
528 def transform(self):
529 return self.primitive.transform
531 @cache_decorator
532 def volume(self) -> float:
533 """
534 The analytic volume of the capsule primitive.
536 Returns
537 ---------
538 volume : float
539 Volume of the capsule
540 """
541 radius, height = self.primitive.radius, self.primitive.height
542 return (np.pi * radius**2) * ((4.0 / 3.0) * radius + height)
544 @cache_decorator
545 def area(self) -> float:
546 """
547 The analytic area of the capsule primitive.
549 Returns
550 ---------
551 area : float
552 Area of the capsule
553 """
554 radius, height = self.primitive.radius, self.primitive.height
555 return (2 * np.pi * radius * height) + (4 * np.pi * radius**2)
557 def to_dict(self):
558 """
559 Get a copy of the current Capsule primitive as
560 a JSON-serializable dict that matches the schema
561 in `trimesh/resources/schema/capsule.schema.json`
563 Returns
564 ----------
565 as_dict : dict
566 Serializable data for this primitive.
567 """
568 return {
569 "kind": "capsule",
570 "transform": self.primitive.transform.tolist(),
571 "height": float(self.primitive.height),
572 "radius": float(self.primitive.radius),
573 }
575 @cache_decorator
576 def direction(self):
577 """
578 The direction of the capsule's axis.
580 Returns
581 --------
582 axis : (3,) float
583 Vector along the cylinder axis
584 """
585 axis = np.dot(self.primitive.transform, [0, 0, 1, 0])[:3]
586 return axis
588 def _create_mesh(self):
589 log.debug("creating mesh for `Capsule` primitive")
591 mesh = creation.capsule(
592 radius=self.primitive.radius,
593 height=self.primitive.height,
594 count=[self.primitive.sections, self.primitive.sections * 2],
595 transform=self.primitive.transform,
596 )
598 self._cache["vertices"] = mesh.vertices
599 self._cache["faces"] = mesh.faces
600 self._cache["face_normals"] = mesh.face_normals
603class Sphere(Primitive):
604 def __init__(
605 self,
606 radius: Number = 1.0,
607 center: ArrayLike | None = None,
608 transform: ArrayLike | None = None,
609 subdivisions: Integer = 3,
610 mutable: bool = True,
611 ):
612 """
613 Create a Sphere Primitive, a subclass of Trimesh.
615 Parameters
616 ----------
617 radius
618 Radius of sphere
619 center : None or (3,) float
620 Center of sphere.
621 transform : None or (4, 4) float
622 Full homogeneous transform. Pass `center` OR `transform.
623 subdivisions
624 Number of subdivisions for icosphere.
625 mutable
626 Are extents and transform mutable after creation.
627 """
629 super().__init__()
631 constructor = {"radius": float(radius), "subdivisions": int(subdivisions)}
632 # center is a helper method for "transform"
633 # since a sphere is rotationally symmetric
634 if center is not None:
635 if transform is not None:
636 raise ValueError("only one of `center` and `transform` may be passed!")
637 translate = np.eye(4)
638 translate[:3, 3] = center
639 constructor["transform"] = translate
640 elif transform is not None:
641 constructor["transform"] = transform
643 # create the attributes object
644 self.primitive = PrimitiveAttributes(
645 self,
646 defaults={"radius": 1.0, "transform": np.eye(4), "subdivisions": 3},
647 kwargs=constructor,
648 mutable=mutable,
649 )
651 @property
652 def center(self):
653 return self.primitive.center
655 @center.setter
656 def center(self, value):
657 self.primitive.center = value
659 def to_dict(self):
660 """
661 Get a copy of the current Sphere primitive as
662 a JSON-serializable dict that matches the schema
663 in `trimesh/resources/schema/sphere.schema.json`
665 Returns
666 ----------
667 as_dict : dict
668 Serializable data for this primitive.
669 """
670 return {
671 "kind": "sphere",
672 "transform": self.primitive.transform.tolist(),
673 "radius": float(self.primitive.radius),
674 }
676 @property
677 def bounds(self):
678 # no docstring so will inherit Trimesh docstring
679 # return exact bounds from primitive center and radius (rather than faces)
680 # self.extents will also use this information
681 bounds = np.array(
682 [
683 self.primitive.center - self.primitive.radius,
684 self.primitive.center + self.primitive.radius,
685 ]
686 )
687 return bounds
689 @property
690 def bounding_box_oriented(self):
691 # for a sphere the oriented bounding box is the same as the axis aligned
692 # bounding box, and a sphere is the absolute slowest case for the OBB calculation
693 # as it is a convex surface with a ton of face normals that all need to
694 # be checked
695 return self.bounding_box
697 @cache_decorator
698 def area(self):
699 """
700 Surface area of the current sphere primitive.
702 Returns
703 --------
704 area: float, surface area of the sphere Primitive
705 """
707 area = 4.0 * np.pi * (self.primitive.radius**2)
708 return area
710 @cache_decorator
711 def volume(self):
712 """
713 Volume of the current sphere primitive.
715 Returns
716 --------
717 volume: float, volume of the sphere Primitive
718 """
720 volume = (4.0 * np.pi * (self.primitive.radius**3)) / 3.0
721 return volume
723 @cache_decorator
724 def moment_inertia(self):
725 """
726 The analytic inertia tensor of the sphere primitive.
728 Returns
729 ----------
730 tensor: (3, 3) float
731 3D inertia tensor.
732 """
733 return inertia.sphere_inertia(mass=self.volume, radius=self.primitive.radius)
735 def _create_mesh(self):
736 log.debug("creating mesh for Sphere primitive")
737 unit = creation.icosphere(
738 subdivisions=self.primitive.subdivisions, radius=self.primitive.radius
739 )
741 # apply the center offset here
742 self._cache["vertices"] = unit.vertices + self.primitive.center
743 self._cache["faces"] = unit.faces
744 self._cache["face_normals"] = unit.face_normals
747class Box(Primitive):
748 def __init__(self, extents=None, transform=None, bounds=None, mutable=True):
749 """
750 Create a Box Primitive as a subclass of Trimesh
752 Parameters
753 ----------
754 extents : ndarray (3,) float or None
755 Length of each side of the 3D box.
756 transform : ndarray (4, 4) float or None
757 Homogeneous transformation matrix for box center.
758 bounds : ndarray (2, 3) float or None
759 Axis aligned bounding box, if passed extents and
760 transform will be derived from this.
761 mutable : bool
762 Are extents and transform mutable after creation.
763 """
764 super().__init__()
765 defaults = {"transform": np.eye(4), "extents": np.ones(3)}
767 if bounds is not None:
768 # validate the multiple forms of input available here
769 if extents is not None or transform is not None:
770 raise ValueError(
771 "if `bounds` is passed `extents` and `transform` must not be!"
772 )
773 bounds = np.array(bounds, dtype=np.float64)
774 if bounds.shape != (2, 3):
775 raise ValueError("`bounds` must be (2, 3) float")
776 # create extents from AABB
777 extents = np.ptp(bounds, axis=0)
778 # translate to the center of the box
779 # use the min corner (not `bounds[0]`) so the result is
780 # independent of the order the two corners are passed in
781 transform = np.eye(4)
782 transform[:3, 3] = np.min(bounds, axis=0) + extents / 2.0
784 self.primitive = PrimitiveAttributes(
785 self,
786 defaults=defaults,
787 kwargs={"extents": extents, "transform": transform},
788 mutable=mutable,
789 )
791 def to_dict(self):
792 """
793 Get a copy of the current Box primitive as
794 a JSON-serializable dict that matches the schema
795 in `trimesh/resources/schema/box.schema.json`
797 Returns
798 ----------
799 as_dict : dict
800 Serializable data for this primitive.
801 """
802 return {
803 "kind": "box",
804 "transform": self.primitive.transform.tolist(),
805 "extents": self.primitive.extents.tolist(),
806 }
808 @property
809 def transform(self):
810 return self.primitive.transform
812 def sample_volume(self, count, seed: Seed = None):
813 """
814 Return random samples from inside the volume of the box.
816 Parameters
817 -------------
818 count : int
819 Number of samples to return
820 seed : None or int
821 Seed for deterministic results, otherwise OS entropy.
823 Returns
824 ----------
825 samples : (count, 3) float
826 Points inside the volume
827 """
828 samples = sample.volume_rectangular(
829 extents=self.primitive.extents,
830 count=count,
831 transform=self.primitive.transform,
832 seed=seed,
833 )
834 return samples
836 def sample_grid(self, count=None, step=None):
837 """
838 Return a 3D grid which is contained by the box.
839 Samples are either 'step' distance apart, or there are
840 'count' samples per box side.
842 Parameters
843 -----------
844 count : int or (3,) int
845 If specified samples are spaced with np.linspace
846 step : float or (3,) float
847 If specified samples are spaced with np.arange
849 Returns
850 -----------
851 grid : (n, 3) float
852 Points inside the box
853 """
855 if count is not None and step is not None:
856 raise ValueError("only step OR count can be specified!")
858 # create pre- transform bounds from extents
859 bounds = np.array([-self.primitive.extents, self.primitive.extents]) * 0.5
861 if step is not None:
862 grid = util.grid_arange(bounds, step=step)
863 elif count is not None:
864 grid = util.grid_linspace(bounds, count=count)
865 else:
866 raise ValueError("either count or step must be specified!")
868 transformed = tf.transform_points(grid, matrix=self.primitive.transform)
869 return transformed
871 @property
872 def is_oriented(self):
873 """
874 Returns whether or not the current box is rotated at all.
875 """
876 if util.is_shape(self.primitive.transform, (4, 4)):
877 return not np.allclose(self.primitive.transform[0:3, 0:3], np.eye(3))
878 else:
879 return False
881 @cache_decorator
882 def volume(self):
883 """
884 Volume of the box Primitive.
886 Returns
887 --------
888 volume : float
889 Volume of box.
890 """
891 volume = float(np.prod(self.primitive.extents))
892 return volume
894 def _create_mesh(self):
895 log.debug("creating mesh for Box primitive")
896 box = creation.box(
897 extents=self.primitive.extents, transform=self.primitive.transform
898 )
900 self._cache.cache.update(box._cache.cache)
901 self._cache["vertices"] = box.vertices
902 self._cache["faces"] = box.faces
903 self._cache["face_normals"] = box.face_normals
905 def as_outline(self):
906 """
907 Return a Path3D containing the outline of the box.
909 Returns
910 -----------
911 outline : trimesh.path.Path3D
912 Outline of box primitive
913 """
914 # do the import in function to keep soft dependency
915 from .path.creation import box_outline
917 # return outline with same size as primitive
918 return box_outline(
919 extents=self.primitive.extents, transform=self.primitive.transform
920 )
923class Extrusion(Primitive):
924 def __init__(
925 self,
926 polygon=None,
927 transform: ArrayLike | None = None,
928 height: Number = 1.0,
929 mutable: bool = True,
930 mid_plane: bool = False,
931 ):
932 """
933 Create an Extrusion primitive, which
934 is a subclass of Trimesh.
936 Parameters
937 ----------
938 polygon : shapely.geometry.Polygon
939 Polygon to extrude
940 transform : (4, 4) float
941 Transform to apply after extrusion
942 height : float
943 Height to extrude polygon by
944 mutable : bool
945 Are extents and transform mutable after creation.
946 """
947 # do the import here, fail early if Shapely isn't installed
948 from shapely.geometry import Point
950 # run the Trimesh init
951 super().__init__()
952 # set default values
953 defaults = {
954 "polygon": Point([0, 0]).buffer(1.0),
955 "transform": np.eye(4),
956 "height": 1.0,
957 "mid_plane": False,
958 }
960 self.primitive = PrimitiveAttributes(
961 self,
962 defaults=defaults,
963 kwargs={
964 "transform": transform,
965 "polygon": polygon,
966 "height": height,
967 "mid_plane": mid_plane,
968 },
969 mutable=mutable,
970 )
972 @cache_decorator
973 def area(self):
974 """
975 The surface area of the primitive extrusion.
977 Calculated from polygon and height to avoid mesh creation.
979 Returns
980 ----------
981 area: float
982 Surface area of 3D extrusion
983 """
984 # area of the sides of the extrusion
985 area = abs(self.primitive.height * self.primitive.polygon.length)
986 # area of the two caps of the extrusion
987 area += self.primitive.polygon.area * 2
988 return area
990 @cache_decorator
991 def volume(self):
992 """
993 The volume of the Extrusion primitive.
994 Calculated from polygon and height to avoid mesh creation.
996 Returns
997 ----------
998 volume : float
999 Volume of 3D extrusion
1000 """
1001 # height may be negative
1002 volume = abs(self.primitive.polygon.area * self.primitive.height)
1003 return volume
1005 @cache_decorator
1006 def direction(self):
1007 """
1008 Based on the extrudes transform what is the
1009 vector along which the polygon will be extruded.
1011 Returns
1012 ---------
1013 direction : (3,) float
1014 Unit direction vector
1015 """
1016 # only consider rotation and signed height
1017 direction = np.dot(
1018 self.primitive.transform[:3, :3], [0.0, 0.0, np.sign(self.primitive.height)]
1019 )
1020 return direction
1022 @property
1023 def origin(self):
1024 """
1025 Based on the extrude transform what is the
1026 origin of the plane it is extruded from.
1028 Returns
1029 -----------
1030 origin : (3,) float
1031 Origin of extrusion plane
1032 """
1033 return self.primitive.transform[:3, 3]
1035 @property
1036 def transform(self):
1037 return self.primitive.transform
1039 @cache_decorator
1040 def bounding_box_oriented(self):
1041 # no docstring for inheritance
1042 # calculate OBB using 2D polygon and known axis
1043 from . import bounds
1045 # find the 2D bounding box using the polygon
1046 to_origin, box = bounds.oriented_bounds_2D(self.primitive.polygon.exterior.coords)
1047 # 3D extents
1048 extents = np.append(box, abs(self.primitive.height))
1049 # calculate to_3D transform from 2D obb
1050 rotation_Z = np.linalg.inv(tf.planar_matrix_to_3D(to_origin))
1051 rotation_Z[2, 3] = self.primitive.height / 2.0
1052 # combine the 2D OBB transformation with the 2D projection transform
1053 to_3D = np.dot(self.primitive.transform, rotation_Z)
1054 return Box(transform=to_3D, extents=extents, mutable=False)
1056 def slide(self, distance):
1057 """
1058 Alter the transform of the current extrusion to slide it
1059 along its extrude_direction vector
1061 Parameters
1062 -----------
1063 distance : float
1064 Distance along self.extrude_direction to move
1065 """
1066 distance = float(distance)
1067 translation = np.eye(4)
1068 translation[2, 3] = distance
1069 new_transform = np.dot(self.primitive.transform.copy(), translation.copy())
1070 self.primitive.transform = new_transform
1072 def buffer(self, distance, distance_height=None, **kwargs):
1073 """
1074 Return a new Extrusion object which is expanded in profile
1075 and in height by a specified distance.
1077 Parameters
1078 --------------
1079 distance : float
1080 Distance to buffer polygon
1081 distance_height : float
1082 Distance to buffer above and below extrusion
1083 kwargs : dict
1084 Passed to Extrusion constructor
1086 Returns
1087 ----------
1088 buffered : primitives.Extrusion
1089 Extrusion object with new values
1090 """
1091 distance = float(distance)
1092 # if not specified use same distance for everything
1093 if distance_height is None:
1094 distance_height = distance
1096 # start with current height
1097 height = self.primitive.height
1098 # if current height is negative offset by negative amount
1099 height += np.sign(height) * 2.0 * distance_height
1101 # create a new extrusion with a buffered polygon
1102 # use type(self) vs Extrusion to handle subclasses
1103 buffered = type(self)(
1104 transform=self.primitive.transform.copy(),
1105 polygon=self.primitive.polygon.buffer(distance),
1106 height=height,
1107 **kwargs,
1108 )
1110 # slide the stock along the axis
1111 buffered.slide(-np.sign(height) * distance_height)
1113 return buffered
1115 def to_dict(self):
1116 """
1117 Get a copy of the current Extrusion primitive as
1118 a JSON-serializable dict that matches the schema
1119 in `trimesh/resources/schema/extrusion.schema.json`
1121 Returns
1122 ----------
1123 as_dict : dict
1124 Serializable data for this primitive.
1125 """
1126 return {
1127 "kind": "extrusion",
1128 "polygon": self.primitive.polygon.wkt,
1129 "transform": self.primitive.transform.tolist(),
1130 "height": float(self.primitive.height),
1131 }
1133 def _create_mesh(self):
1134 log.debug("creating mesh for Extrusion primitive")
1135 # extrude the polygon along Z
1136 mesh = creation.extrude_polygon(
1137 polygon=self.primitive.polygon,
1138 height=self.primitive.height,
1139 transform=self.primitive.transform,
1140 mid_plane=self.primitive.mid_plane,
1141 )
1143 # check volume here in unit tests
1144 if tol.strict and mesh.volume < 0.0:
1145 raise ValueError("matrix inverted mesh!")
1147 # cache mesh geometry in the primitive
1148 self._cache["vertices"] = mesh.vertices
1149 self._cache["faces"] = mesh.faces