Coverage for trimesh/parent.py: 90%
150 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"""
2parent.py
3-------------
5The base class for Trimesh, PointCloud, and Scene objects
6"""
8import abc
9import os
10from dataclasses import dataclass
11from typing import Any
13import numpy as np
15from . import bounds, caching
16from . import transformations as tf
17from .caching import cache_decorator
18from .constants import tol
19from .resolvers import ResolverLike
20from .typed import ArrayLike, NDArray, float64
21from .util import ABC
24@dataclass
25class LoadSource:
26 """
27 Save information about where a particular object was loaded from.
28 """
30 # a file-like object that can be accessed
31 file_obj: Any | None = None
33 # a cleaned file type string, i.e. "stl"
34 file_type: str | None = None
36 # if this was originally loaded from a file path
37 # save it here so we can check it later.
38 file_path: str | None = None
40 # did we open `file_obj` ourselves?
41 was_opened: bool = False
43 # a resolver for loading assets next to the file
44 resolver: ResolverLike | None = None
46 @property
47 def file_name(self) -> str | None:
48 """
49 Get just the file name from the path if available.
51 Returns
52 ---------
53 file_name
54 Just the file name, i.e. for file_path="/a/b/c.stl" -> "c.stl"
55 """
56 if self.file_path is None:
57 return None
58 return os.path.basename(self.file_path)
60 def __getstate__(self) -> dict[str, Any]:
61 # this overrides the `pickle.dump` behavior for this class
62 # we cannot pickle a file object so return `file_obj: None` for pickles
63 return {k: v if k != "file_obj" else None for k, v in self.__dict__.items()}
65 def __deepcopy__(self, *args):
66 return LoadSource(**self.__getstate__())
69class Geometry(ABC):
70 """
71 `Geometry` is the parent class for all geometry.
73 By decorating a method with `abc.abstractmethod` it means
74 the objects that inherit from `Geometry` MUST implement
75 those methods.
76 """
78 # geometry should have a dict to store loose metadata
79 metadata: dict[str, Any]
81 @property
82 def source(self) -> LoadSource:
83 """
84 Where and what was this current geometry loaded from?
86 Returns
87 --------
88 source
89 If loaded from a file, has the path, type, etc.
90 """
91 # this should have been tacked on by the loader
92 # but we want to *always* be able to access
93 # a value like `mesh.source.file_type` so add a default
94 current = getattr(self, "_source", None)
95 if current is not None:
96 return current
97 self._source = LoadSource()
98 return self._source
100 @property
101 @abc.abstractmethod
102 def identifier_hash(self) -> str:
103 pass
105 @property
106 @abc.abstractmethod
107 def bounds(self) -> NDArray[np.float64]:
108 pass
110 @property
111 @abc.abstractmethod
112 def extents(self) -> NDArray[np.float64]:
113 pass
115 @abc.abstractmethod
116 def apply_transform(self, matrix: ArrayLike) -> Any:
117 pass
119 @property
120 @abc.abstractmethod
121 def is_empty(self) -> bool:
122 pass
124 def __hash__(self):
125 """
126 Get a hash of the current geometry.
128 Returns
129 ---------
130 hash
131 Hash of current graph and geometry.
132 """
133 return self._data.__hash__() # type: ignore
135 @abc.abstractmethod
136 def copy(self):
137 pass
139 @abc.abstractmethod
140 def show(self):
141 pass
143 @abc.abstractmethod
144 def __add__(self, other):
145 pass
147 @abc.abstractmethod
148 def export(self, file_obj, file_type=None):
149 pass
151 def __repr__(self) -> str:
152 """
153 Print quick summary of the current geometry without
154 computing properties.
156 Returns
157 -----------
158 repr : str
159 Human readable quick look at the geometry.
160 """
161 elements = []
162 if hasattr(self, "vertices"):
163 # for Trimesh and PointCloud
164 elements.append(f"vertices.shape={self.vertices.shape}")
165 if hasattr(self, "faces"):
166 # for Trimesh
167 elements.append(f"faces.shape={self.faces.shape}")
168 if hasattr(self, "geometry") and isinstance(self.geometry, dict):
169 # for Scene
170 elements.append(f"len(geometry)={len(self.geometry)}")
171 if "Voxel" in type(self).__name__:
172 # for VoxelGrid objects
173 elements.append(str(self.shape)[1:-1])
174 if "file_name" in self.metadata:
175 display = self.metadata["file_name"]
176 elements.append(f"name=`{display}`")
177 return "<trimesh.{}({})>".format(type(self).__name__, ", ".join(elements))
179 def apply_translation(self, translation: ArrayLike):
180 """
181 Translate the current mesh.
183 Parameters
184 ----------
185 translation : (3,) float
186 Translation in XYZ
187 """
188 translation = np.asanyarray(translation, dtype=np.float64)
189 if translation.shape == (2,):
190 # create a planar matrix if we were passed a 2D offset
191 return self.apply_transform(tf.planar_matrix(offset=translation))
192 elif translation.shape != (3,):
193 raise ValueError("Translation must be (3,) or (2,)!")
195 # manually create a translation matrix
196 matrix = np.eye(4)
197 matrix[:3, 3] = translation
198 return self.apply_transform(matrix)
200 def apply_scale(self, scaling):
201 """
202 Scale the mesh.
204 Parameters
205 ----------
206 scaling : float or (3,) float
207 Scale factor to apply to the mesh
208 """
209 matrix = tf.scale_and_translate(scale=scaling)
210 # apply_transform will work nicely even on negative scales
211 return self.apply_transform(matrix)
213 def __radd__(self, other):
214 """
215 Concatenate the geometry allowing concatenation with
216 built in `sum()` function:
217 `sum(Iterable[trimesh.Trimesh])`
219 Parameters
220 ------------
221 other : Geometry
222 Geometry or 0
224 Returns
225 ----------
226 concat : Geometry
227 Geometry of combined result
228 """
230 if other == 0:
231 # adding 0 to a geometry never makes sense
232 return self
233 # otherwise just use the regular add function
234 return self.__add__(type(self)(other))
236 @cache_decorator
237 def scale(self) -> float:
238 """
239 A loosely specified "order of magnitude scale" for the
240 geometry which always returns a value and can be used
241 to make code more robust to large scaling differences.
243 It returns the diagonal of the axis aligned bounding box
244 or if anything is invalid or undefined, `1.0`.
246 Returns
247 ----------
248 scale : float
249 Approximate order of magnitude scale of the geometry.
250 """
251 # if geometry is empty return 1.0
252 if self.extents is None:
253 return 1.0
255 # get the length of the AABB diagonal
256 scale = float((self.extents**2).sum() ** 0.5)
257 if scale < tol.zero:
258 return 1.0
260 return scale
262 @property
263 def units(self) -> str | None:
264 """
265 Definition of units for the mesh.
267 Returns
268 ----------
269 units : str
270 Unit system mesh is in, or None if not defined
271 """
272 return self.metadata.get("units", None)
274 @units.setter
275 def units(self, value: str) -> None:
276 """
277 Define the units of the current mesh.
278 """
279 self.metadata["units"] = str(value).lower().strip()
282class Geometry3D(Geometry):
283 """
284 The `Geometry3D` object is the parent object of geometry objects
285 which are three dimensional, including Trimesh, PointCloud,
286 and Scene objects.
287 """
289 @caching.cache_decorator
290 def bounding_box(self):
291 """
292 An axis aligned bounding box for the current mesh.
294 Returns
295 ----------
296 aabb : trimesh.primitives.Box
297 Box object with transform and extents defined
298 representing the axis aligned bounding box of the mesh
299 """
300 from . import primitives
302 transform = np.eye(4)
303 # translate to center of axis aligned bounds
304 transform[:3, 3] = self.bounds.mean(axis=0)
306 return primitives.Box(transform=transform, extents=self.extents, mutable=False)
308 @caching.cache_decorator
309 def bounding_box_oriented(self):
310 """
311 An oriented bounding box for the current mesh.
313 Returns
314 ---------
315 obb : trimesh.primitives.Box
316 Box object with transform and extents defined
317 representing the minimum volume oriented
318 bounding box of the mesh
319 """
320 from . import bounds, primitives
322 to_origin, extents = bounds.oriented_bounds(self)
323 return primitives.Box(
324 transform=np.linalg.inv(to_origin), extents=extents, mutable=False
325 )
327 @caching.cache_decorator
328 def bounding_sphere(self):
329 """
330 A minimum volume bounding sphere for the current mesh.
332 Note that the Sphere primitive returned has an unpadded
333 exact `sphere_radius` so while the distance of every vertex
334 of the current mesh from sphere_center will be less than
335 sphere_radius, the faceted sphere primitive may not
336 contain every vertex.
338 Returns
339 --------
340 minball : trimesh.primitives.Sphere
341 Sphere primitive containing current mesh
342 """
343 from . import nsphere, primitives
345 center, radius = nsphere.minimum_nsphere(self)
346 return primitives.Sphere(center=center, radius=radius, mutable=False)
348 @caching.cache_decorator
349 def bounding_cylinder(self):
350 """
351 A minimum volume bounding cylinder for the current mesh.
353 Returns
354 --------
355 mincyl : trimesh.primitives.Cylinder
356 Cylinder primitive containing current mesh
357 """
358 from . import bounds, primitives
360 kwargs = bounds.minimum_cylinder(self)
361 return primitives.Cylinder(mutable=False, **kwargs)
363 @caching.cache_decorator
364 def bounding_primitive(self):
365 """
366 The minimum volume primitive (box, sphere, or cylinder) that
367 bounds the mesh.
369 Returns
370 ---------
371 bounding_primitive : object
372 Smallest primitive which bounds the mesh:
373 trimesh.primitives.Sphere
374 trimesh.primitives.Box
375 trimesh.primitives.Cylinder
376 """
377 options = [
378 self.bounding_box_oriented,
379 self.bounding_sphere,
380 self.bounding_cylinder,
381 ]
382 volume_min = np.argmin([i.volume for i in options])
383 return options[volume_min]
385 def apply_obb(self, **kwargs) -> NDArray[float64]:
386 """
387 Apply the oriented bounding box transform to the current mesh.
389 This will result in a mesh with an AABB centered at the
390 origin and the same dimensions as the OBB.
392 Parameters
393 ------------
394 kwargs
395 Passed through to `bounds.oriented_bounds`
397 Returns
398 ----------
399 matrix : (4, 4) float
400 Transformation matrix that was applied
401 to mesh to move it into OBB frame
402 """
403 # save the pre-transform volume
404 if tol.strict and hasattr(self, "volume"):
405 volume = self.volume
407 # calculate the OBB passing keyword arguments through
408 matrix, extents = bounds.oriented_bounds(self, **kwargs)
409 # apply the transform
410 self.apply_transform(matrix)
412 if tol.strict:
413 # obb transform should not have changed volume
414 if hasattr(self, "volume") and getattr(self, "is_watertight", False):
415 assert np.isclose(self.volume, volume)
416 # overall extents should match what we expected
417 assert np.allclose(self.extents, extents)
419 return matrix