Coverage for trimesh/primitives.py: 94%
369 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"""
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
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):
811 """
812 Return random samples from inside the volume of the box.
814 Parameters
815 -------------
816 count : int
817 Number of samples to return
819 Returns
820 ----------
821 samples : (count, 3) float
822 Points inside the volume
823 """
824 samples = sample.volume_rectangular(
825 extents=self.primitive.extents,
826 count=count,
827 transform=self.primitive.transform,
828 )
829 return samples
831 def sample_grid(self, count=None, step=None):
832 """
833 Return a 3D grid which is contained by the box.
834 Samples are either 'step' distance apart, or there are
835 'count' samples per box side.
837 Parameters
838 -----------
839 count : int or (3,) int
840 If specified samples are spaced with np.linspace
841 step : float or (3,) float
842 If specified samples are spaced with np.arange
844 Returns
845 -----------
846 grid : (n, 3) float
847 Points inside the box
848 """
850 if count is not None and step is not None:
851 raise ValueError("only step OR count can be specified!")
853 # create pre- transform bounds from extents
854 bounds = np.array([-self.primitive.extents, self.primitive.extents]) * 0.5
856 if step is not None:
857 grid = util.grid_arange(bounds, step=step)
858 elif count is not None:
859 grid = util.grid_linspace(bounds, count=count)
860 else:
861 raise ValueError("either count or step must be specified!")
863 transformed = tf.transform_points(grid, matrix=self.primitive.transform)
864 return transformed
866 @property
867 def is_oriented(self):
868 """
869 Returns whether or not the current box is rotated at all.
870 """
871 if util.is_shape(self.primitive.transform, (4, 4)):
872 return not np.allclose(self.primitive.transform[0:3, 0:3], np.eye(3))
873 else:
874 return False
876 @cache_decorator
877 def volume(self):
878 """
879 Volume of the box Primitive.
881 Returns
882 --------
883 volume : float
884 Volume of box.
885 """
886 volume = float(np.prod(self.primitive.extents))
887 return volume
889 def _create_mesh(self):
890 log.debug("creating mesh for Box primitive")
891 box = creation.box(
892 extents=self.primitive.extents, transform=self.primitive.transform
893 )
895 self._cache.cache.update(box._cache.cache)
896 self._cache["vertices"] = box.vertices
897 self._cache["faces"] = box.faces
898 self._cache["face_normals"] = box.face_normals
900 def as_outline(self):
901 """
902 Return a Path3D containing the outline of the box.
904 Returns
905 -----------
906 outline : trimesh.path.Path3D
907 Outline of box primitive
908 """
909 # do the import in function to keep soft dependency
910 from .path.creation import box_outline
912 # return outline with same size as primitive
913 return box_outline(
914 extents=self.primitive.extents, transform=self.primitive.transform
915 )
918class Extrusion(Primitive):
919 def __init__(
920 self,
921 polygon=None,
922 transform: ArrayLike | None = None,
923 height: Number = 1.0,
924 mutable: bool = True,
925 mid_plane: bool = False,
926 ):
927 """
928 Create an Extrusion primitive, which
929 is a subclass of Trimesh.
931 Parameters
932 ----------
933 polygon : shapely.geometry.Polygon
934 Polygon to extrude
935 transform : (4, 4) float
936 Transform to apply after extrusion
937 height : float
938 Height to extrude polygon by
939 mutable : bool
940 Are extents and transform mutable after creation.
941 """
942 # do the import here, fail early if Shapely isn't installed
943 from shapely.geometry import Point
945 # run the Trimesh init
946 super().__init__()
947 # set default values
948 defaults = {
949 "polygon": Point([0, 0]).buffer(1.0),
950 "transform": np.eye(4),
951 "height": 1.0,
952 "mid_plane": False,
953 }
955 self.primitive = PrimitiveAttributes(
956 self,
957 defaults=defaults,
958 kwargs={
959 "transform": transform,
960 "polygon": polygon,
961 "height": height,
962 "mid_plane": mid_plane,
963 },
964 mutable=mutable,
965 )
967 @cache_decorator
968 def area(self):
969 """
970 The surface area of the primitive extrusion.
972 Calculated from polygon and height to avoid mesh creation.
974 Returns
975 ----------
976 area: float
977 Surface area of 3D extrusion
978 """
979 # area of the sides of the extrusion
980 area = abs(self.primitive.height * self.primitive.polygon.length)
981 # area of the two caps of the extrusion
982 area += self.primitive.polygon.area * 2
983 return area
985 @cache_decorator
986 def volume(self):
987 """
988 The volume of the Extrusion primitive.
989 Calculated from polygon and height to avoid mesh creation.
991 Returns
992 ----------
993 volume : float
994 Volume of 3D extrusion
995 """
996 # height may be negative
997 volume = abs(self.primitive.polygon.area * self.primitive.height)
998 return volume
1000 @cache_decorator
1001 def direction(self):
1002 """
1003 Based on the extrudes transform what is the
1004 vector along which the polygon will be extruded.
1006 Returns
1007 ---------
1008 direction : (3,) float
1009 Unit direction vector
1010 """
1011 # only consider rotation and signed height
1012 direction = np.dot(
1013 self.primitive.transform[:3, :3], [0.0, 0.0, np.sign(self.primitive.height)]
1014 )
1015 return direction
1017 @property
1018 def origin(self):
1019 """
1020 Based on the extrude transform what is the
1021 origin of the plane it is extruded from.
1023 Returns
1024 -----------
1025 origin : (3,) float
1026 Origin of extrusion plane
1027 """
1028 return self.primitive.transform[:3, 3]
1030 @property
1031 def transform(self):
1032 return self.primitive.transform
1034 @cache_decorator
1035 def bounding_box_oriented(self):
1036 # no docstring for inheritance
1037 # calculate OBB using 2D polygon and known axis
1038 from . import bounds
1040 # find the 2D bounding box using the polygon
1041 to_origin, box = bounds.oriented_bounds_2D(self.primitive.polygon.exterior.coords)
1042 # 3D extents
1043 extents = np.append(box, abs(self.primitive.height))
1044 # calculate to_3D transform from 2D obb
1045 rotation_Z = np.linalg.inv(tf.planar_matrix_to_3D(to_origin))
1046 rotation_Z[2, 3] = self.primitive.height / 2.0
1047 # combine the 2D OBB transformation with the 2D projection transform
1048 to_3D = np.dot(self.primitive.transform, rotation_Z)
1049 return Box(transform=to_3D, extents=extents, mutable=False)
1051 def slide(self, distance):
1052 """
1053 Alter the transform of the current extrusion to slide it
1054 along its extrude_direction vector
1056 Parameters
1057 -----------
1058 distance : float
1059 Distance along self.extrude_direction to move
1060 """
1061 distance = float(distance)
1062 translation = np.eye(4)
1063 translation[2, 3] = distance
1064 new_transform = np.dot(self.primitive.transform.copy(), translation.copy())
1065 self.primitive.transform = new_transform
1067 def buffer(self, distance, distance_height=None, **kwargs):
1068 """
1069 Return a new Extrusion object which is expanded in profile
1070 and in height by a specified distance.
1072 Parameters
1073 --------------
1074 distance : float
1075 Distance to buffer polygon
1076 distance_height : float
1077 Distance to buffer above and below extrusion
1078 kwargs : dict
1079 Passed to Extrusion constructor
1081 Returns
1082 ----------
1083 buffered : primitives.Extrusion
1084 Extrusion object with new values
1085 """
1086 distance = float(distance)
1087 # if not specified use same distance for everything
1088 if distance_height is None:
1089 distance_height = distance
1091 # start with current height
1092 height = self.primitive.height
1093 # if current height is negative offset by negative amount
1094 height += np.sign(height) * 2.0 * distance_height
1096 # create a new extrusion with a buffered polygon
1097 # use type(self) vs Extrusion to handle subclasses
1098 buffered = type(self)(
1099 transform=self.primitive.transform.copy(),
1100 polygon=self.primitive.polygon.buffer(distance),
1101 height=height,
1102 **kwargs,
1103 )
1105 # slide the stock along the axis
1106 buffered.slide(-np.sign(height) * distance_height)
1108 return buffered
1110 def to_dict(self):
1111 """
1112 Get a copy of the current Extrusion primitive as
1113 a JSON-serializable dict that matches the schema
1114 in `trimesh/resources/schema/extrusion.schema.json`
1116 Returns
1117 ----------
1118 as_dict : dict
1119 Serializable data for this primitive.
1120 """
1121 return {
1122 "kind": "extrusion",
1123 "polygon": self.primitive.polygon.wkt,
1124 "transform": self.primitive.transform.tolist(),
1125 "height": float(self.primitive.height),
1126 }
1128 def _create_mesh(self):
1129 log.debug("creating mesh for Extrusion primitive")
1130 # extrude the polygon along Z
1131 mesh = creation.extrude_polygon(
1132 polygon=self.primitive.polygon,
1133 height=self.primitive.height,
1134 transform=self.primitive.transform,
1135 mid_plane=self.primitive.mid_plane,
1136 )
1138 # check volume here in unit tests
1139 if tol.strict and mesh.volume < 0.0:
1140 raise ValueError("matrix inverted mesh!")
1142 # cache mesh geometry in the primitive
1143 self._cache["vertices"] = mesh.vertices
1144 self._cache["faces"] = mesh.faces