Coverage for trimesh/primitives.py: 94%
369 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"""
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, height=self.primitive.height
593 )
594 mesh.apply_transform(self.primitive.transform)
596 self._cache["vertices"] = mesh.vertices
597 self._cache["faces"] = mesh.faces
598 self._cache["face_normals"] = mesh.face_normals
601class Sphere(Primitive):
602 def __init__(
603 self,
604 radius: Number = 1.0,
605 center: ArrayLike | None = None,
606 transform: ArrayLike | None = None,
607 subdivisions: Integer = 3,
608 mutable: bool = True,
609 ):
610 """
611 Create a Sphere Primitive, a subclass of Trimesh.
613 Parameters
614 ----------
615 radius
616 Radius of sphere
617 center : None or (3,) float
618 Center of sphere.
619 transform : None or (4, 4) float
620 Full homogeneous transform. Pass `center` OR `transform.
621 subdivisions
622 Number of subdivisions for icosphere.
623 mutable
624 Are extents and transform mutable after creation.
625 """
627 super().__init__()
629 constructor = {"radius": float(radius), "subdivisions": int(subdivisions)}
630 # center is a helper method for "transform"
631 # since a sphere is rotationally symmetric
632 if center is not None:
633 if transform is not None:
634 raise ValueError("only one of `center` and `transform` may be passed!")
635 translate = np.eye(4)
636 translate[:3, 3] = center
637 constructor["transform"] = translate
638 elif transform is not None:
639 constructor["transform"] = transform
641 # create the attributes object
642 self.primitive = PrimitiveAttributes(
643 self,
644 defaults={"radius": 1.0, "transform": np.eye(4), "subdivisions": 3},
645 kwargs=constructor,
646 mutable=mutable,
647 )
649 @property
650 def center(self):
651 return self.primitive.center
653 @center.setter
654 def center(self, value):
655 self.primitive.center = value
657 def to_dict(self):
658 """
659 Get a copy of the current Sphere primitive as
660 a JSON-serializable dict that matches the schema
661 in `trimesh/resources/schema/sphere.schema.json`
663 Returns
664 ----------
665 as_dict : dict
666 Serializable data for this primitive.
667 """
668 return {
669 "kind": "sphere",
670 "transform": self.primitive.transform.tolist(),
671 "radius": float(self.primitive.radius),
672 }
674 @property
675 def bounds(self):
676 # no docstring so will inherit Trimesh docstring
677 # return exact bounds from primitive center and radius (rather than faces)
678 # self.extents will also use this information
679 bounds = np.array(
680 [
681 self.primitive.center - self.primitive.radius,
682 self.primitive.center + self.primitive.radius,
683 ]
684 )
685 return bounds
687 @property
688 def bounding_box_oriented(self):
689 # for a sphere the oriented bounding box is the same as the axis aligned
690 # bounding box, and a sphere is the absolute slowest case for the OBB calculation
691 # as it is a convex surface with a ton of face normals that all need to
692 # be checked
693 return self.bounding_box
695 @cache_decorator
696 def area(self):
697 """
698 Surface area of the current sphere primitive.
700 Returns
701 --------
702 area: float, surface area of the sphere Primitive
703 """
705 area = 4.0 * np.pi * (self.primitive.radius**2)
706 return area
708 @cache_decorator
709 def volume(self):
710 """
711 Volume of the current sphere primitive.
713 Returns
714 --------
715 volume: float, volume of the sphere Primitive
716 """
718 volume = (4.0 * np.pi * (self.primitive.radius**3)) / 3.0
719 return volume
721 @cache_decorator
722 def moment_inertia(self):
723 """
724 The analytic inertia tensor of the sphere primitive.
726 Returns
727 ----------
728 tensor: (3, 3) float
729 3D inertia tensor.
730 """
731 return inertia.sphere_inertia(mass=self.volume, radius=self.primitive.radius)
733 def _create_mesh(self):
734 log.debug("creating mesh for Sphere primitive")
735 unit = creation.icosphere(
736 subdivisions=self.primitive.subdivisions, radius=self.primitive.radius
737 )
739 # apply the center offset here
740 self._cache["vertices"] = unit.vertices + self.primitive.center
741 self._cache["faces"] = unit.faces
742 self._cache["face_normals"] = unit.face_normals
745class Box(Primitive):
746 def __init__(self, extents=None, transform=None, bounds=None, mutable=True):
747 """
748 Create a Box Primitive as a subclass of Trimesh
750 Parameters
751 ----------
752 extents : ndarray (3,) float or None
753 Length of each side of the 3D box.
754 transform : ndarray (4, 4) float or None
755 Homogeneous transformation matrix for box center.
756 bounds : ndarray (2, 3) float or None
757 Axis aligned bounding box, if passed extents and
758 transform will be derived from this.
759 mutable : bool
760 Are extents and transform mutable after creation.
761 """
762 super().__init__()
763 defaults = {"transform": np.eye(4), "extents": np.ones(3)}
765 if bounds is not None:
766 # validate the multiple forms of input available here
767 if extents is not None or transform is not None:
768 raise ValueError(
769 "if `bounds` is passed `extents` and `transform` must not be!"
770 )
771 bounds = np.array(bounds, dtype=np.float64)
772 if bounds.shape != (2, 3):
773 raise ValueError("`bounds` must be (2, 3) float")
774 # create extents from AABB
775 extents = np.ptp(bounds, axis=0)
776 # translate to the center of the box
777 # use the min corner (not `bounds[0]`) so the result is
778 # independent of the order the two corners are passed in
779 transform = np.eye(4)
780 transform[:3, 3] = np.min(bounds, axis=0) + extents / 2.0
782 self.primitive = PrimitiveAttributes(
783 self,
784 defaults=defaults,
785 kwargs={"extents": extents, "transform": transform},
786 mutable=mutable,
787 )
789 def to_dict(self):
790 """
791 Get a copy of the current Box primitive as
792 a JSON-serializable dict that matches the schema
793 in `trimesh/resources/schema/box.schema.json`
795 Returns
796 ----------
797 as_dict : dict
798 Serializable data for this primitive.
799 """
800 return {
801 "kind": "box",
802 "transform": self.primitive.transform.tolist(),
803 "extents": self.primitive.extents.tolist(),
804 }
806 @property
807 def transform(self):
808 return self.primitive.transform
810 def sample_volume(self, count, seed: Seed = None):
811 """
812 Return random samples from inside the volume of the box.
814 Parameters
815 -------------
816 count : int
817 Number of samples to return
818 seed : None or int
819 Seed for deterministic results, otherwise OS entropy.
821 Returns
822 ----------
823 samples : (count, 3) float
824 Points inside the volume
825 """
826 samples = sample.volume_rectangular(
827 extents=self.primitive.extents,
828 count=count,
829 transform=self.primitive.transform,
830 seed=seed,
831 )
832 return samples
834 def sample_grid(self, count=None, step=None):
835 """
836 Return a 3D grid which is contained by the box.
837 Samples are either 'step' distance apart, or there are
838 'count' samples per box side.
840 Parameters
841 -----------
842 count : int or (3,) int
843 If specified samples are spaced with np.linspace
844 step : float or (3,) float
845 If specified samples are spaced with np.arange
847 Returns
848 -----------
849 grid : (n, 3) float
850 Points inside the box
851 """
853 if count is not None and step is not None:
854 raise ValueError("only step OR count can be specified!")
856 # create pre- transform bounds from extents
857 bounds = np.array([-self.primitive.extents, self.primitive.extents]) * 0.5
859 if step is not None:
860 grid = util.grid_arange(bounds, step=step)
861 elif count is not None:
862 grid = util.grid_linspace(bounds, count=count)
863 else:
864 raise ValueError("either count or step must be specified!")
866 transformed = tf.transform_points(grid, matrix=self.primitive.transform)
867 return transformed
869 @property
870 def is_oriented(self):
871 """
872 Returns whether or not the current box is rotated at all.
873 """
874 if util.is_shape(self.primitive.transform, (4, 4)):
875 return not np.allclose(self.primitive.transform[0:3, 0:3], np.eye(3))
876 else:
877 return False
879 @cache_decorator
880 def volume(self):
881 """
882 Volume of the box Primitive.
884 Returns
885 --------
886 volume : float
887 Volume of box.
888 """
889 volume = float(np.prod(self.primitive.extents))
890 return volume
892 def _create_mesh(self):
893 log.debug("creating mesh for Box primitive")
894 box = creation.box(
895 extents=self.primitive.extents, transform=self.primitive.transform
896 )
898 self._cache.cache.update(box._cache.cache)
899 self._cache["vertices"] = box.vertices
900 self._cache["faces"] = box.faces
901 self._cache["face_normals"] = box.face_normals
903 def as_outline(self):
904 """
905 Return a Path3D containing the outline of the box.
907 Returns
908 -----------
909 outline : trimesh.path.Path3D
910 Outline of box primitive
911 """
912 # do the import in function to keep soft dependency
913 from .path.creation import box_outline
915 # return outline with same size as primitive
916 return box_outline(
917 extents=self.primitive.extents, transform=self.primitive.transform
918 )
921class Extrusion(Primitive):
922 def __init__(
923 self,
924 polygon=None,
925 transform: ArrayLike | None = None,
926 height: Number = 1.0,
927 mutable: bool = True,
928 mid_plane: bool = False,
929 ):
930 """
931 Create an Extrusion primitive, which
932 is a subclass of Trimesh.
934 Parameters
935 ----------
936 polygon : shapely.geometry.Polygon
937 Polygon to extrude
938 transform : (4, 4) float
939 Transform to apply after extrusion
940 height : float
941 Height to extrude polygon by
942 mutable : bool
943 Are extents and transform mutable after creation.
944 """
945 # do the import here, fail early if Shapely isn't installed
946 from shapely.geometry import Point
948 # run the Trimesh init
949 super().__init__()
950 # set default values
951 defaults = {
952 "polygon": Point([0, 0]).buffer(1.0),
953 "transform": np.eye(4),
954 "height": 1.0,
955 "mid_plane": False,
956 }
958 self.primitive = PrimitiveAttributes(
959 self,
960 defaults=defaults,
961 kwargs={
962 "transform": transform,
963 "polygon": polygon,
964 "height": height,
965 "mid_plane": mid_plane,
966 },
967 mutable=mutable,
968 )
970 @cache_decorator
971 def area(self):
972 """
973 The surface area of the primitive extrusion.
975 Calculated from polygon and height to avoid mesh creation.
977 Returns
978 ----------
979 area: float
980 Surface area of 3D extrusion
981 """
982 # area of the sides of the extrusion
983 area = abs(self.primitive.height * self.primitive.polygon.length)
984 # area of the two caps of the extrusion
985 area += self.primitive.polygon.area * 2
986 return area
988 @cache_decorator
989 def volume(self):
990 """
991 The volume of the Extrusion primitive.
992 Calculated from polygon and height to avoid mesh creation.
994 Returns
995 ----------
996 volume : float
997 Volume of 3D extrusion
998 """
999 # height may be negative
1000 volume = abs(self.primitive.polygon.area * self.primitive.height)
1001 return volume
1003 @cache_decorator
1004 def direction(self):
1005 """
1006 Based on the extrudes transform what is the
1007 vector along which the polygon will be extruded.
1009 Returns
1010 ---------
1011 direction : (3,) float
1012 Unit direction vector
1013 """
1014 # only consider rotation and signed height
1015 direction = np.dot(
1016 self.primitive.transform[:3, :3], [0.0, 0.0, np.sign(self.primitive.height)]
1017 )
1018 return direction
1020 @property
1021 def origin(self):
1022 """
1023 Based on the extrude transform what is the
1024 origin of the plane it is extruded from.
1026 Returns
1027 -----------
1028 origin : (3,) float
1029 Origin of extrusion plane
1030 """
1031 return self.primitive.transform[:3, 3]
1033 @property
1034 def transform(self):
1035 return self.primitive.transform
1037 @cache_decorator
1038 def bounding_box_oriented(self):
1039 # no docstring for inheritance
1040 # calculate OBB using 2D polygon and known axis
1041 from . import bounds
1043 # find the 2D bounding box using the polygon
1044 to_origin, box = bounds.oriented_bounds_2D(self.primitive.polygon.exterior.coords)
1045 # 3D extents
1046 extents = np.append(box, abs(self.primitive.height))
1047 # calculate to_3D transform from 2D obb
1048 rotation_Z = np.linalg.inv(tf.planar_matrix_to_3D(to_origin))
1049 rotation_Z[2, 3] = self.primitive.height / 2.0
1050 # combine the 2D OBB transformation with the 2D projection transform
1051 to_3D = np.dot(self.primitive.transform, rotation_Z)
1052 return Box(transform=to_3D, extents=extents, mutable=False)
1054 def slide(self, distance):
1055 """
1056 Alter the transform of the current extrusion to slide it
1057 along its extrude_direction vector
1059 Parameters
1060 -----------
1061 distance : float
1062 Distance along self.extrude_direction to move
1063 """
1064 distance = float(distance)
1065 translation = np.eye(4)
1066 translation[2, 3] = distance
1067 new_transform = np.dot(self.primitive.transform.copy(), translation.copy())
1068 self.primitive.transform = new_transform
1070 def buffer(self, distance, distance_height=None, **kwargs):
1071 """
1072 Return a new Extrusion object which is expanded in profile
1073 and in height by a specified distance.
1075 Parameters
1076 --------------
1077 distance : float
1078 Distance to buffer polygon
1079 distance_height : float
1080 Distance to buffer above and below extrusion
1081 kwargs : dict
1082 Passed to Extrusion constructor
1084 Returns
1085 ----------
1086 buffered : primitives.Extrusion
1087 Extrusion object with new values
1088 """
1089 distance = float(distance)
1090 # if not specified use same distance for everything
1091 if distance_height is None:
1092 distance_height = distance
1094 # start with current height
1095 height = self.primitive.height
1096 # if current height is negative offset by negative amount
1097 height += np.sign(height) * 2.0 * distance_height
1099 # create a new extrusion with a buffered polygon
1100 # use type(self) vs Extrusion to handle subclasses
1101 buffered = type(self)(
1102 transform=self.primitive.transform.copy(),
1103 polygon=self.primitive.polygon.buffer(distance),
1104 height=height,
1105 **kwargs,
1106 )
1108 # slide the stock along the axis
1109 buffered.slide(-np.sign(height) * distance_height)
1111 return buffered
1113 def to_dict(self):
1114 """
1115 Get a copy of the current Extrusion primitive as
1116 a JSON-serializable dict that matches the schema
1117 in `trimesh/resources/schema/extrusion.schema.json`
1119 Returns
1120 ----------
1121 as_dict : dict
1122 Serializable data for this primitive.
1123 """
1124 return {
1125 "kind": "extrusion",
1126 "polygon": self.primitive.polygon.wkt,
1127 "transform": self.primitive.transform.tolist(),
1128 "height": float(self.primitive.height),
1129 }
1131 def _create_mesh(self):
1132 log.debug("creating mesh for Extrusion primitive")
1133 # extrude the polygon along Z
1134 mesh = creation.extrude_polygon(
1135 polygon=self.primitive.polygon,
1136 height=self.primitive.height,
1137 transform=self.primitive.transform,
1138 mid_plane=self.primitive.mid_plane,
1139 )
1141 # check volume here in unit tests
1142 if tol.strict and mesh.volume < 0.0:
1143 raise ValueError("matrix inverted mesh!")
1145 # cache mesh geometry in the primitive
1146 self._cache["vertices"] = mesh.vertices
1147 self._cache["faces"] = mesh.faces