Coverage for trimesh/scene/scene.py: 90%
508 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
1import collections
2import uuid
3import warnings
4from copy import deepcopy
5from hashlib import sha256
6from typing import TypeAlias
8# ruff doesn't recognize this correctly when we re-import it from trimesh.typed -_-
9import numpy as np
11from .. import caching, convex, grouping, inertia, transformations, units, util
12from ..constants import log
13from ..exchange import export
14from ..parent import Geometry, Geometry3D
15from ..registration import procrustes
16from ..typed import (
17 ArrayLike,
18 Floating,
19 Hashable,
20 Integer,
21 Iterable,
22 NDArray,
23 Sequence,
24 ViewerType,
25 float64,
26 int64,
27)
28from ..util import unique_name
29from . import cameras, lighting
30from .transforms import SceneGraph
32# the types of objects we can create a scene from
33GeometryInput: TypeAlias = Geometry | Iterable[Geometry] | dict[str, Geometry] | ArrayLike
36class Scene(Geometry3D):
37 """
38 A simple scene graph which can be rendered directly via
39 pyglet/openGL or through other endpoints such as a
40 raytracer. Meshes are added by name, which can then be
41 moved by updating transform in the transform tree.
42 """
44 def __init__(
45 self,
46 geometry: GeometryInput | None = None,
47 base_frame: Hashable = "world",
48 metadata: dict | None = None,
49 graph: SceneGraph | None = None,
50 camera: cameras.Camera | None = None,
51 lights: Sequence[lighting.Light] | None = None,
52 camera_transform: NDArray | None = None,
53 ):
54 """
55 Create a new Scene object.
57 Parameters
58 -------------
59 geometry : Trimesh, Path2D, Path3D PointCloud or list
60 Geometry to initially add to the scene
61 base_frame
62 Name of base frame
63 metadata
64 Any metadata about the scene
65 graph
66 A passed transform graph to use
67 camera : Camera or None
68 A passed camera to use
69 lights : [trimesh.scene.lighting.Light] or None
70 A passed lights to use
71 camera_transform
72 Homogeneous (4, 4) camera transform in the base frame
73 """
74 # mesh name : Trimesh object
75 self.geometry = collections.OrderedDict()
77 # create a new graph
78 self.graph = SceneGraph(base_frame=base_frame)
80 # create our cache
81 self._cache = caching.Cache(id_function=self.__hash__)
83 if geometry is not None:
84 # add passed geometry to scene
85 self.add_geometry(geometry)
87 # hold metadata about the scene
88 self.metadata = {}
89 if isinstance(metadata, dict):
90 self.metadata.update(metadata)
92 if graph is not None:
93 # if we've been passed a graph override the default
94 self.graph = graph
96 if lights is not None:
97 self.lights = lights
98 if camera is not None:
99 self.camera = camera
100 if camera_transform is not None:
101 self.camera_transform = camera_transform
103 def apply_transform(self, transform):
104 """
105 Apply a transform to all children of the base frame
106 without modifying any geometry.
108 Parameters
109 --------------
110 transform : (4, 4)
111 Homogeneous transformation matrix.
112 """
113 base = self.graph.base_frame
114 for child in self.graph.transforms.children[base]:
115 combined = np.dot(transform, self.graph[child][0])
116 self.graph.update(frame_from=base, frame_to=child, matrix=combined)
117 return self
119 def add_geometry(
120 self,
121 geometry: GeometryInput,
122 node_name: Hashable | None = None,
123 geom_name: str | None = None,
124 parent_node_name: Hashable | None = None,
125 transform: NDArray | None = None,
126 metadata: dict | None = None,
127 ):
128 """
129 Add a geometry to the scene.
131 If the mesh has multiple transforms defined in its
132 metadata, they will all be copied into the
133 TransformForest of the current scene automatically.
135 Parameters
136 ----------
137 geometry : Trimesh, Path2D, Path3D PointCloud or list
138 Geometry to initially add to the scene
139 node_name : None or str
140 Name of the added node.
141 geom_name : None or str
142 Name of the added geometry.
143 parent_node_name : None or str
144 Name of the parent node in the graph.
145 transform : None or (4, 4) float
146 Transform that applies to the added node.
147 metadata : None or dict
148 Optional metadata for the node.
150 Returns
151 ----------
152 node_name : str
153 Name of single node in self.graph (passed in) or None if
154 node was not added (eg. geometry was null or a Scene).
155 """
157 if geometry is None:
158 return
159 # PointCloud objects will look like a sequence
160 elif util.is_sequence(geometry):
161 # if passed a sequence add all elements
162 return [
163 self.add_geometry(
164 geometry=value,
165 node_name=node_name,
166 geom_name=geom_name,
167 parent_node_name=parent_node_name,
168 transform=transform,
169 metadata=metadata,
170 )
171 for value in geometry # type: ignore
172 ]
173 elif isinstance(geometry, dict):
174 # if someone passed us a dict of geometry
175 return {
176 k: self.add_geometry(geometry=v, geom_name=k, metadata=metadata)
177 for k, v in geometry.items()
178 }
180 elif isinstance(geometry, Scene):
181 # concatenate current scene with passed scene
182 concat = self + geometry
183 # replace geometry in-place
184 self.geometry.clear()
185 self.geometry.update(concat.geometry)
186 # replace graph data with concatenated graph
187 self.graph.transforms = concat.graph.transforms
188 return
190 # get or create a name to reference the geometry by
191 if geom_name is not None:
192 # if name is passed use it
193 name = geom_name
194 elif "name" in geometry.metadata:
195 # if name is in metadata use it
196 name = geometry.metadata["name"]
197 elif geometry.source.file_name is not None:
198 name = geometry.source.file_name
199 else:
200 # try to create a simple name
201 name = "geometry_" + str(len(self.geometry))
203 # if its already taken use our unique name logic
204 name = unique_name(start=name, contains=self.geometry.keys())
205 # save the geometry reference
206 self.geometry[name] = geometry
208 # create a unique node name if not passed
209 if node_name is None:
210 # if the name of the geometry is also a transform node
211 # which graph nodes already exist
212 existing = self.graph.transforms.node_data.keys()
213 # find a name that isn't contained already starting
214 # at the name we have
215 node_name = unique_name(name, existing)
216 assert node_name not in existing
218 if transform is None:
219 # create an identity transform from parent_node
220 transform = np.eye(4)
222 self.graph.update(
223 frame_to=node_name,
224 frame_from=parent_node_name,
225 matrix=transform,
226 geometry=name,
227 geometry_flags={"visible": True},
228 metadata=metadata,
229 )
231 return node_name
233 def delete_geometry(self, names: set | str | Sequence) -> None:
234 """
235 Delete one more multiple geometries from the scene and also
236 remove any node in the transform graph which references it.
238 Parameters
239 --------------
240 name : hashable
241 Name that references self.geometry
242 """
243 # make sure we have a set we can check
244 if isinstance(names, str):
245 names = [names]
246 names = set(names)
248 # remove the geometry reference from relevant nodes
249 self.graph.remove_geometries(names)
250 # remove the geometries from our geometry store
251 [self.geometry.pop(name, None) for name in names]
253 def strip_visuals(self) -> None:
254 """
255 Strip visuals from every Trimesh geometry
256 and set them to an empty `ColorVisuals`.
257 """
258 from ..visual.color import ColorVisuals
260 for geometry in self.geometry.values():
261 if util.is_instance_named(geometry, "Trimesh"):
262 geometry.visual = ColorVisuals(mesh=geometry)
264 def simplify_quadric_decimation(
265 self,
266 percent: Floating | None = None,
267 face_count: Integer | None = None,
268 aggression: Integer | None = None,
269 ) -> None:
270 """
271 Apply in-place `mesh.simplify_quadric_decimation` to any meshes
272 in the scene.
274 Parameters
275 -----------
276 percent
277 A number between 0.0 and 1.0 for how much
278 face_count
279 Target number of faces desired in the resulting mesh.
280 aggression
281 An integer between `0` and `10`, the scale being roughly
282 `0` is "slow and good" and `10` being "fast and bad."
284 """
285 # save the updates for after the loop
286 updates = {}
287 for k, v in self.geometry.items():
288 if hasattr(v, "simplify_quadric_decimation"):
289 updates[k] = v.simplify_quadric_decimation(
290 percent=percent, face_count=face_count, aggression=aggression
291 )
292 self.geometry.update(updates)
294 def __hash__(self) -> int:
295 """
296 Return information about scene which is hashable.
298 Returns
299 ---------
300 hashed
301 String hashing scene.
302 """
303 # avoid accessing attribute in tight loop
304 geometry = self.geometry
305 # hash of geometry and transforms
306 # start with the last modified time of the scene graph
307 hashable = [hex(self.graph.transforms.__hash__())]
308 # take the re-hex string of the hash
309 hashable.extend(hex(geometry[k].__hash__()) for k in geometry.keys())
310 return caching.hash_fast("".join(hashable).encode("utf-8"))
312 @property
313 def is_empty(self) -> bool:
314 """
315 Does the scene have anything in it.
317 Returns
318 ----------
319 is_empty
320 True if nothing is in the scene
321 """
323 return len(self.geometry) == 0
325 @property
326 def is_valid(self) -> bool:
327 """
328 Is every geometry connected to the root node.
330 Returns
331 -----------
332 is_valid : bool
333 Does every geometry have a transform
334 """
335 if len(self.geometry) == 0:
336 return True
338 try:
339 referenced = {self.graph[i][1] for i in self.graph.nodes_geometry}
340 except BaseException:
341 # if connectivity to world frame is broken return false
342 return False
344 # every geometry is referenced
345 return referenced == set(self.geometry.keys())
347 @caching.cache_decorator
348 def bounds_corners(self) -> dict[str, NDArray[float64]]:
349 """
350 Get the post-transform AABB for each node
351 which has geometry defined.
353 Returns
354 -----------
355 corners
356 Bounds for each node with vertices:
357 {node_name : (2, 3) float}
358 """
359 # collect AABB for each geometry
360 corners = {}
361 # collect vertices for every mesh
362 vertices = {
363 k: m.vertices if hasattr(m, "vertices") and len(m.vertices) > 0 else m.bounds
364 for k, m in self.geometry.items()
365 }
366 # handle 2D geometries
367 vertices.update(
368 {
369 k: np.column_stack((v, np.zeros(len(v))))
370 for k, v in vertices.items()
371 if v is not None and v.shape[1] == 2
372 }
373 )
375 # loop through every node with geometry
376 for node_name in self.graph.nodes_geometry:
377 # access the transform and geometry name from node
378 transform, geometry_name = self.graph[node_name]
379 # will be None if no vertices for this node
380 points = vertices.get(geometry_name)
381 # skip empty geometries
382 if points is None:
383 continue
384 # apply just the rotation to skip N multiplies
385 dot = np.dot(transform[:3, :3], points.T)
386 # append the AABB with translation applied after
387 corners[node_name] = np.array(
388 [dot.min(axis=1) + transform[:3, 3], dot.max(axis=1) + transform[:3, 3]]
389 )
390 return corners
392 @caching.cache_decorator
393 def bounds(self) -> NDArray[float64] | None:
394 """
395 Return the overall bounding box of the scene.
397 Returns
398 --------
399 bounds : (2, 3) float or None
400 Position of [min, max] bounding box
401 Returns None if no valid bounds exist
402 """
403 bounds_corners = self.bounds_corners
404 if len(bounds_corners) == 0:
405 return None
406 # combine each geometry node AABB into a larger list
407 corners = np.vstack(list(self.bounds_corners.values()))
408 return np.array([corners.min(axis=0), corners.max(axis=0)], dtype=np.float64)
410 @caching.cache_decorator
411 def extents(self) -> NDArray[float64] | None:
412 """
413 Return the axis aligned box size of the current scene
414 or None if the scene is empty.
416 Returns
417 ----------
418 extents
419 Bounding box sides length or None for empty scene.
420 """
421 bounds = self.bounds
422 if bounds is None:
423 return None
424 return np.diff(bounds, axis=0).reshape(-1)
426 @caching.cache_decorator
427 def scale(self) -> float:
428 """
429 The approximate scale of the mesh
431 Returns
432 -----------
433 scale : float
434 The mean of the bounding box edge lengths
435 """
436 extents = self.extents
437 if extents is None:
438 return 1.0
439 return float((extents**2).sum() ** 0.5)
441 @caching.cache_decorator
442 def centroid(self) -> NDArray[float64] | None:
443 """
444 Return the center of the bounding box for the scene.
446 Returns
447 --------
448 centroid : (3) float
449 Point for center of bounding box
450 """
451 bounds = self.bounds
452 if bounds is None:
453 return None
454 centroid = np.mean(self.bounds, axis=0)
455 return centroid
457 @caching.cache_decorator
458 def center_mass(self) -> NDArray:
459 """
460 Find the center of mass for every instance in the scene.
462 Returns
463 ------------
464 center_mass : (3,) float
465 The center of mass of the scene
466 """
467 # get the center of mass and volume for each geometry
468 center_mass = {
469 k: m.center_mass
470 for k, m in self.geometry.items()
471 if hasattr(m, "center_mass")
472 }
473 mass = {k: m.mass for k, m in self.geometry.items() if hasattr(m, "mass")}
475 # get the geometry name and transform for each instance
476 graph = self.graph
477 instance = [graph[n] for n in graph.nodes_geometry]
479 # get the transformed center of mass for each instance
480 transformed = np.array(
481 [
482 np.dot(mat, np.append(center_mass[g], 1))[:3]
483 for mat, g in instance
484 if g in center_mass
485 ],
486 dtype=np.float64,
487 )
488 # weight the center of mass locations by volume
489 weights = np.array([mass[g] for _, g in instance], dtype=np.float64)
490 weights /= weights.sum()
491 return (transformed * weights.reshape((-1, 1))).sum(axis=0)
493 @caching.cache_decorator
494 def moment_inertia(self):
495 """
496 Return the moment of inertia of the current scene with
497 respect to the center of mass of the current scene.
499 Returns
500 ------------
501 inertia : (3, 3) float
502 Inertia with respect to cartesian axis at `scene.center_mass`
503 """
504 return inertia.scene_inertia(
505 scene=self, transform=transformations.translation_matrix(self.center_mass)
506 )
508 def moment_inertia_frame(self, transform):
509 """
510 Return the moment of inertia of the current scene relative
511 to a transform from the base frame.
513 Parameters
514 transform : (4, 4) float
515 Homogeneous transformation matrix.
517 Returns
518 -------------
519 inertia : (3, 3) float
520 Inertia tensor at requested frame.
521 """
522 return inertia.scene_inertia(scene=self, transform=transform)
524 @caching.cache_decorator
525 def area(self) -> float:
526 """
527 What is the summed area of every geometry which
528 has area.
530 Returns
531 ------------
532 area : float
533 Summed area of every instanced geometry
534 """
535 # get the area of every geometry that has an area property
536 areas = {n: g.area for n, g in self.geometry.items() if hasattr(g, "area")}
537 # sum the area including instancing
538 return sum(
539 (areas.get(self.graph[n][1], 0.0) for n in self.graph.nodes_geometry), 0.0
540 )
542 @caching.cache_decorator
543 def volume(self) -> float64:
544 """
545 What is the summed volume of every geometry which
546 has volume
548 Returns
549 ------------
550 volume : float
551 Summed area of every instanced geometry
552 """
553 # get the area of every geometry that has a volume attribute
554 volume = {n: g.volume for n, g in self.geometry.items() if hasattr(g, "area")}
555 # sum the area including instancing
556 return sum(
557 (volume.get(self.graph[n][1], 0.0) for n in self.graph.nodes_geometry), 0.0
558 )
560 @caching.cache_decorator
561 def triangles(self) -> NDArray[float64]:
562 """
563 Return a correctly transformed polygon soup of the
564 current scene.
566 Returns
567 ----------
568 triangles : (n, 3, 3) float
569 Triangles in space
570 """
571 triangles = []
572 triangles_node = []
573 for node_name in self.graph.nodes_geometry:
574 # which geometry does this node refer to
575 transform, geometry_name = self.graph[node_name]
577 # get the actual potential mesh instance
578 geometry = self.geometry[geometry_name]
579 if not hasattr(geometry, "triangles"):
580 continue
581 # append the (n, 3, 3) triangles to a sequence
582 triangles.append(
583 transformations.transform_points(
584 geometry.triangles.copy().reshape((-1, 3)), matrix=transform
585 )
586 )
587 # save the node names for each triangle
588 triangles_node.append(np.tile(node_name, len(geometry.triangles)))
589 # save the resulting nodes to the cache
590 self._cache["triangles_node"] = np.hstack(triangles_node)
591 return np.vstack(triangles).reshape((-1, 3, 3))
593 @caching.cache_decorator
594 def triangles_node(self):
595 """
596 Which node of self.graph does each triangle come from.
598 Returns
599 ---------
600 triangles_index : (len(self.triangles),)
601 Node name for each triangle
602 """
603 populate = self.triangles # NOQA
604 return self._cache["triangles_node"]
606 @caching.cache_decorator
607 def geometry_identifiers(self) -> dict[str, str]:
608 """
609 Look up geometries by identifier hash.
611 Returns
612 ---------
613 identifiers
614 {Identifier hash: key in self.geometry}
615 """
616 return {mesh.identifier_hash: name for name, mesh in self.geometry.items()}
618 @caching.cache_decorator
619 def identifier_hash(self) -> str:
620 """
621 Get a unique identifier for the scene.
622 """
623 dump = "".join(g.identifier_hash for g in self.geometry.values()) + str(
624 hash(self.graph)
625 )
626 return sha256(dump.encode()).hexdigest()
628 @caching.cache_decorator
629 def duplicate_nodes(self) -> list[list[str]]:
630 """
631 Return a sequence of node keys of identical meshes.
633 Will include meshes with different geometry but identical
634 spatial hashes as well as meshes repeated by self.nodes.
636 Returns
637 -----------
638 duplicates
639 Keys of self.graph that represent identical geometry
640 """
641 # if there is no geometry we can have no duplicate nodes
642 if len(self.geometry) == 0:
643 return []
645 # geometry name : hash of mesh
646 hashes = {
647 k: int(m.identifier_hash, 16)
648 for k, m in self.geometry.items()
649 if hasattr(m, "identifier_hash")
650 }
652 # bring into local scope for loop
653 graph = self.graph
654 # get a hash for each node name
655 # scene.graph node name : hashed geometry
656 node_hash = {node: hashes.get(graph[node][1]) for node in graph.nodes_geometry}
658 # collect node names for each hash key
659 duplicates = collections.defaultdict(list)
660 # use a slightly off-label list comprehension
661 # for debatable function call overhead avoidance
662 [
663 duplicates[hashed].append(node)
664 for node, hashed in node_hash.items()
665 if hashed is not None
666 ]
668 # we only care about the values keys are garbage
669 return list(duplicates.values())
671 def reconstruct_instances(self, cost_threshold: Floating = 1e-5) -> "Scene":
672 """
673 If a scene has been "baked" with meshes it means that
674 the duplicate nodes have *corresponding vertices* but are
675 rigidly transformed to different places.
677 This means the problem of finding ab instance transform can
678 use the `procrustes` analysis which is *very* fast relative
679 to more complicated registration problems that require ICP
680 and nearest-point-on-surface calculations.
682 TODO : construct a parent non-geometry node for containing every group.
684 Parameters
685 ----------
686 scene
687 The scene to handle.
688 cost_threshold
689 The maximum value for `procrustes` cost which is "squared mean
690 vertex distance between pair". If the fit is above this value
691 the instance will be left even if it is a duplicate.
693 Returns
694 ---------
695 dedupe
696 A copy of the scene de-duplicated as much as possible.
697 """
698 return reconstruct_instances(self, cost_threshold=cost_threshold)
700 def set_camera(
701 self, angles=None, distance=None, center=None, resolution=None, fov=None
702 ) -> cameras.Camera:
703 """
704 Create a camera object for self.camera, and add
705 a transform to self.graph for it.
707 If arguments are not passed sane defaults will be figured
708 out which show the mesh roughly centered.
710 Parameters
711 -----------
712 angles : (3,) float
713 Initial euler angles in radians
714 distance : float
715 Distance from centroid
716 center : (3,) float
717 Point camera should be center on
718 camera : Camera object
719 Object that stores camera parameters
720 """
722 if fov is None:
723 fov = np.array([60, 45])
725 # if no geometry nothing to set camera to
726 if len(self.geometry) == 0:
727 self._camera = cameras.Camera(fov=fov)
728 self.graph[self._camera.name] = np.eye(4)
729 return self._camera
730 # set with no rotation by default
731 if angles is None:
732 angles = np.zeros(3)
734 rotation = transformations.euler_matrix(*angles)
735 transform = cameras.look_at(
736 self.bounds, fov=fov, rotation=rotation, distance=distance, center=center
737 )
739 if hasattr(self, "_camera") and self._camera is not None:
740 self._camera.fov = fov
741 if resolution is not None:
742 self._camera.resolution = resolution
743 else:
744 # create a new camera object
745 self._camera = cameras.Camera(fov=fov, resolution=resolution)
747 self.graph[self._camera.name] = transform
749 return self._camera
751 @property
752 def camera_transform(self):
753 """
754 Get camera transform in the base frame.
756 Returns
757 -------
758 camera_transform : (4, 4) float
759 Camera transform in the base frame
760 """
761 return self.graph[self.camera.name][0]
763 @camera_transform.setter
764 def camera_transform(self, matrix: ArrayLike):
765 """
766 Set the camera transform in the base frame
768 Parameters
769 ----------
770 camera_transform : (4, 4) float
771 Camera transform in the base frame
772 """
773 self.graph[self.camera.name] = matrix
775 def camera_rays(self) -> tuple[NDArray[float64], NDArray[float64], NDArray[int64]]:
776 """
777 Calculate the trimesh.scene.Camera origin and ray
778 direction vectors. Returns one ray per pixel as set
779 in camera.resolution
781 Returns
782 --------------
783 origin: (n, 3) float
784 Ray origins in space
785 vectors: (n, 3) float
786 Ray direction unit vectors in world coordinates
787 pixels : (n, 2) int
788 Which pixel does each ray correspond to in an image
789 """
790 # get the unit vectors of the camera
791 vectors, pixels = self.camera.to_rays()
792 # find our scene's transform for the camera
793 transform = self.camera_transform
794 # apply the rotation to the unit ray direction vectors
795 vectors = transformations.transform_points(vectors, transform, translate=False)
796 # camera origin is single point so extract from
797 origins = np.ones_like(vectors) * transformations.translation_from_matrix(
798 transform
799 )
800 return origins, vectors, pixels
802 @property
803 def camera(self) -> cameras.Camera:
804 """
805 Get the single camera for the scene. If not manually
806 set one will abe automatically generated.
808 Returns
809 ----------
810 camera : trimesh.scene.Camera
811 Camera object defined for the scene
812 """
813 # no camera set for the scene yet
814 if not self.has_camera:
815 # will create a camera with everything in view
816 return self.set_camera()
817 assert self._camera is not None
819 return self._camera
821 @camera.setter
822 def camera(self, camera: cameras.Camera | None):
823 """
824 Set a camera object for the Scene.
826 Parameters
827 -----------
828 camera : trimesh.scene.Camera
829 Camera object for the scene
830 """
831 if camera is None:
832 return
833 self._camera = camera
835 @property
836 def has_camera(self) -> bool:
837 return hasattr(self, "_camera") and self._camera is not None
839 @property
840 def lights(self) -> list[lighting.Light]:
841 """
842 Get a list of the lights in the scene. If nothing is
843 set it will generate some automatically.
845 Returns
846 -------------
847 lights : [trimesh.scene.lighting.Light]
848 Lights in the scene.
849 """
850 if not hasattr(self, "_lights") or self._lights is None:
851 # do some automatic lighting
852 lights, transforms = lighting.autolight(self)
853 # assign the transforms to the scene graph
854 for L, T in zip(lights, transforms):
855 self.graph[L.name] = T
856 # set the lights
857 self._lights = lights
858 return self._lights
860 @lights.setter
861 def lights(self, lights: Sequence[lighting.Light]):
862 """
863 Assign a list of light objects to the scene
865 Parameters
866 --------------
867 lights : [trimesh.scene.lighting.Light]
868 Lights in the scene.
869 """
870 self._lights = lights
872 def rezero(self) -> None:
873 """
874 Move the current scene so that the AABB of the whole
875 scene is centered at the origin.
877 Does this by changing the base frame to a new, offset
878 base frame.
879 """
880 if self.is_empty or np.allclose(self.centroid, 0.0):
881 # early exit since what we want already exists
882 return
884 # the transformation to move the overall scene to AABB centroid
885 matrix = np.eye(4)
886 matrix[:3, 3] = -self.centroid
888 # we are going to change the base frame
889 new_base = str(self.graph.base_frame) + "_I"
890 self.graph.update(
891 frame_from=new_base, frame_to=self.graph.base_frame, matrix=matrix
892 )
893 self.graph.base_frame = new_base
895 def dump(self, concatenate: bool = False) -> list[Geometry]:
896 """
897 Get a list of every geometry moved to its instance position,
898 i.e. freezing or "baking" transforms.
900 Parameters
901 ------------
902 concatenate
903 KWARG IS DEPRECATED FOR REMOVAL APRIL 2025
904 Concatenate results into single geometry.
905 This keyword argument will make the type hint incorrect and
906 you should replace `Scene.dump(concatenate=True)` with:
907 - `Scene.to_geometry()` for a Trimesh, Path2D or Path3D
908 - `Scene.to_mesh()` for only `Trimesh` components.
910 Returns
911 ----------
912 dumped
913 Copies of `Scene.geometry` transformed to their instance position.
914 """
916 result = []
917 for node_name in self.graph.nodes_geometry:
918 transform, geometry_name = self.graph[node_name]
919 # get a copy of the geometry
920 current = self.geometry[geometry_name].copy()
922 # if the geometry is 2D see if we have to upgrade to 3D
923 if hasattr(current, "to_3D"):
924 # check to see if the scene is transforming the path out of plane
925 check = util.isclose(transform, util._IDENTITY, atol=1e-8)
926 check[:2, :3] = True
927 if not check.all():
928 # transform moves in 3D so we put this on the Z=0 plane
929 current = current.to_3D()
930 else:
931 # transform moves in 2D so clip off the last row and column
932 transform = transform[:3, :3]
934 # move the geometry vertices into the requested frame
935 current.apply_transform(transform)
936 current.metadata["name"] = geometry_name
937 current.metadata["node"] = node_name
939 # save to our list of meshes
940 result.append(current)
942 if concatenate:
943 warnings.warn(
944 "`Scene.dump(concatenate=True)` DEPRECATED FOR REMOVAL APRIL 2025: replace with `Scene.to_geometry()`",
945 category=DeprecationWarning,
946 stacklevel=2,
947 )
948 # if scene has mixed geometry this may drop some of it
949 return util.concatenate(result) # type: ignore
951 return result
953 def to_mesh(self) -> "trimesh.Trimesh": # noqa: F821
954 """
955 Concatenate every mesh instances in the scene into a single mesh,
956 applying transforms and "baking" the result. Will drop any geometry
957 in the scene that is not a `Trimesh` object.
959 Returns
960 ----------
961 mesh
962 All meshes in the scene concatenated into one.
963 """
964 from ..base import Trimesh
966 # concatenate only meshes
967 return util.concatenate([d for d in self.dump() if isinstance(d, Trimesh)])
969 def to_geometry(self) -> Geometry:
970 """
971 Concatenate geometry in the scene into a single like-typed geometry,
972 applying the transforms and "baking" the result. May drop geometry
973 if the scene has mixed geometry.
975 Returns
976 ---------
977 concat
978 Either a Trimesh, Path2D, or Path3D depending on what is in the scene.
979 """
980 # concatenate everything and return the most-occurring type.
981 return util.concatenate(self.dump())
983 def subscene(self, node: Hashable) -> "Scene":
984 """
985 Get part of a scene that succeeds a specified node.
987 Parameters
988 ------------
989 node
990 Hashable key in `scene.graph`
992 Returns
993 -----------
994 subscene
995 Partial scene generated from current.
996 """
997 # get every node that is a successor to specified node
998 # this includes `node`
999 graph = self.graph
1000 nodes = graph.transforms.successors(node)
1001 # get every edge that has an included node
1002 edges = [e for e in graph.to_edgelist() if e[0] in nodes]
1004 # create a scene graph when
1005 graph = SceneGraph(base_frame=node)
1006 graph.from_edgelist(edges)
1008 geometry_names = {e[2]["geometry"] for e in edges if "geometry" in e[2]}
1009 geometry = {k: self.geometry[k] for k in geometry_names}
1010 result = Scene(geometry=geometry, graph=graph)
1011 return result
1013 @caching.cache_decorator
1014 def convex_hull(self):
1015 """
1016 The convex hull of the whole scene.
1018 Returns
1019 ---------
1020 hull : trimesh.Trimesh
1021 Trimesh object which is a convex hull of all meshes in scene
1022 """
1023 points = util.vstack_empty([m.vertices for m in self.dump()]) # type: ignore
1024 return convex.convex_hull(points)
1026 def export(self, file_obj=None, file_type=None, **kwargs):
1027 """
1028 Export a snapshot of the current scene.
1030 Parameters
1031 ----------
1032 file_obj : str, file-like, or None
1033 File object to export to
1034 file_type : str or None
1035 What encoding to use for meshes
1036 IE: dict, dict64, stl
1038 Returns
1039 ----------
1040 export : bytes
1041 Only returned if file_obj is None
1042 """
1043 return export.export_scene(
1044 scene=self, file_obj=file_obj, file_type=file_type, **kwargs
1045 )
1047 def save_image(self, resolution=None, **kwargs) -> bytes:
1048 """
1049 Get a PNG image of a scene.
1051 Parameters
1052 -----------
1053 resolution : (2,) int
1054 Resolution to render image
1055 **kwargs
1056 Passed to SceneViewer constructor
1058 Returns
1059 -----------
1060 png : bytes
1061 Render of scene as a PNG
1062 """
1063 from ..viewer.windowed import render_scene
1065 return render_scene(
1066 scene=self, resolution=resolution, fullscreen=False, resizable=False, **kwargs
1067 )
1069 @property
1070 def units(self) -> str | None:
1071 """
1072 Get the units for every model in the scene. If the scene has
1073 mixed units or no units this will return None.
1075 Returns
1076 -----------
1077 units
1078 Units for every model in the scene or None
1079 if there are no units or mixed units
1080 """
1081 # get a set of the units of every geometry
1082 existing = {i.units for i in self.geometry.values()}
1083 if len(existing) == 1:
1084 return existing.pop()
1085 elif len(existing) > 1:
1086 log.warning(f"Mixed units `{existing}` returning None")
1087 return None
1089 @units.setter
1090 def units(self, value: str):
1091 """
1092 Set the units for every model in the scene without
1093 converting any units just setting the tag.
1095 Parameters
1096 ------------
1097 value : str
1098 Value to set every geometry unit value to
1099 """
1100 value = value.strip().lower()
1101 for m in self.geometry.values():
1102 m.units = value
1104 def convert_units(self, desired: str, guess: bool = False) -> "Scene":
1105 """
1106 If geometry has units defined convert them to new units.
1108 Returns a new scene with geometries and transforms scaled.
1110 Parameters
1111 ----------
1112 desired : str
1113 Desired final unit system: 'inches', 'mm', etc.
1114 guess : bool
1115 Is the converter allowed to guess scale when models
1116 don't have it specified in their metadata.
1118 Returns
1119 ----------
1120 scaled : trimesh.Scene
1121 Copy of scene with scaling applied and units set
1122 for every model
1123 """
1124 # if there is no geometry do nothing
1125 if len(self.geometry) == 0:
1126 return self.copy()
1128 current = self.units
1129 if current is None:
1130 # will raise ValueError if not in metadata
1131 # and not allowed to guess
1132 current = units.units_from_metadata(self, guess=guess)
1134 # find the float conversion
1135 scale = units.unit_conversion(current=current, desired=desired)
1137 # apply scaling factor or exit early if scale ~= 1.0
1138 result = self.scaled(scale=scale)
1140 # apply the units to every geometry of the scaled result
1141 result.units = desired
1143 return result
1145 def explode(self, vector=None, origin=None) -> None:
1146 """
1147 Explode the current scene in-place around a point and vector.
1149 Parameters
1150 -----------
1151 vector : (3,) float or float
1152 Explode radially around a direction vector or spherically
1153 origin : (3,) float
1154 Point to explode around
1155 """
1156 if origin is None:
1157 origin = self.centroid
1158 if vector is None:
1159 vector = self.scale / 25.0
1161 vector = np.asanyarray(vector, dtype=np.float64)
1162 origin = np.asanyarray(origin, dtype=np.float64)
1164 for node_name in self.graph.nodes_geometry:
1165 transform, geometry_name = self.graph[node_name]
1166 centroid = self.geometry[geometry_name].centroid
1167 # transform centroid into nodes location
1168 centroid = np.dot(transform, np.append(centroid, 1))[:3]
1170 if vector.shape == ():
1171 # case where our vector is a single number
1172 offset = (centroid - origin) * vector
1173 elif np.shape(vector) == (3,):
1174 projected = np.dot(vector, (centroid - origin))
1175 offset = vector * projected
1176 else:
1177 raise ValueError("explode vector wrong shape!")
1179 # original transform is read-only
1180 T_new = transform.copy()
1181 T_new[:3, 3] += offset
1182 self.graph[node_name] = T_new
1184 def scaled(self, scale: Floating | ArrayLike) -> "Scene":
1185 """
1186 Return a copy of the current scene, with meshes and scene
1187 transforms scaled to the requested factor.
1189 Parameters
1190 -----------
1191 scale : float or (3,) float
1192 Factor to scale meshes and transforms
1194 Returns
1195 -----------
1196 scaled : trimesh.Scene
1197 A copy of the current scene but scaled
1198 """
1199 result = self.copy()
1201 # a scale of 1.0 is a no-op
1202 if np.allclose(scale, 1.0):
1203 return result
1205 # convert 2D geometries to 3D for 3D scaling factors
1206 scale_is_3D = isinstance(scale, (list, tuple, np.ndarray)) and len(scale) == 3
1208 if scale_is_3D and np.all(np.asarray(scale) == scale[0]):
1209 # scale is uniform
1210 scale = float(scale[0])
1211 scale_is_3D = False
1212 elif not scale_is_3D:
1213 scale = float(scale)
1215 # result is a copy
1217 if scale_is_3D:
1218 # Copy all geometries that appear multiple times in the scene,
1219 # such that no two nodes share the same geometry.
1220 # This is required since the non-uniform scaling will most likely
1221 # affect the same geometry in different poses differently.
1222 # Note, that this is not needed in the case of uniform scaling.
1223 for geom_name in result.graph.geometry_nodes:
1224 nodes_with_geom = result.graph.geometry_nodes[geom_name]
1225 if len(nodes_with_geom) > 1:
1226 geom = result.geometry[geom_name]
1227 for n in nodes_with_geom:
1228 p = result.graph.transforms.parents[n]
1229 result.add_geometry(
1230 geometry=geom.copy(),
1231 geom_name=geom_name,
1232 node_name=n,
1233 parent_node_name=p,
1234 transform=result.graph.transforms.edge_data[(p, n)].get(
1235 "matrix", None
1236 ),
1237 metadata=result.graph.transforms.edge_data[(p, n)].get(
1238 "metadata", None
1239 ),
1240 )
1241 result.delete_geometry(geom_name)
1243 # Convert all 2D paths to 3D paths
1244 for geom_name in result.geometry:
1245 if result.geometry[geom_name].vertices.shape[1] == 2:
1246 result.geometry[geom_name] = result.geometry[geom_name].to_3D()
1248 for key in result.graph.nodes_geometry:
1249 T, geom_name = result.graph.get(key)
1250 # transform from graph should be read-only
1251 T = T.copy()
1252 T[:3, 3] = 0.0
1254 # Get geometry transform w.r.t. base frame
1255 result.geometry[geom_name].apply_transform(T).apply_scale(
1256 scale
1257 ).apply_transform(np.linalg.inv(T))
1259 # Scale all transformations in the scene graph
1260 edge_data = result.graph.transforms.edge_data
1261 for uv in edge_data:
1262 if "matrix" in edge_data[uv]:
1263 props = edge_data[uv]
1264 T = edge_data[uv]["matrix"].copy()
1265 T[:3, 3] *= scale
1266 props["matrix"] = T
1267 result.graph.update(frame_from=uv[0], frame_to=uv[1], **props)
1268 # Clear cache
1269 result.graph.transforms._cache = {}
1270 result.graph.transforms._modified = str(uuid.uuid4())
1271 result.graph._cache.clear()
1272 else:
1273 # matrix for 2D scaling
1274 scale_2D = np.eye(3) * scale
1275 # matrix for 3D scaling
1276 scale_3D = np.eye(4) * scale
1278 # preallocate transforms and geometries
1279 nodes = np.array(self.graph.nodes_geometry)
1280 transforms = np.zeros((len(nodes), 4, 4))
1281 geometries = [None] * len(nodes)
1283 # collect list of transforms
1284 for i, node in enumerate(nodes):
1285 transforms[i], geometries[i] = self.graph[node]
1287 # remove all existing transforms
1288 result.graph.clear()
1290 for group in grouping.group(geometries):
1291 # hashable reference to self.geometry
1292 geometry = geometries[group[0]]
1293 # original transform from world to geometry
1294 original = transforms[group[0]]
1295 # transform for geometry
1296 new_geom = np.dot(scale_3D, original)
1298 if result.geometry[geometry].vertices.shape[1] == 2:
1299 # if our scene is 2D only scale in 2D
1300 result.geometry[geometry].apply_transform(scale_2D)
1301 else:
1302 # otherwise apply the full transform
1303 result.geometry[geometry].apply_transform(new_geom)
1305 for node, T in zip(nodes[group], transforms[group]):
1306 # generate the new transforms
1307 transform = util.multi_dot([scale_3D, T, np.linalg.inv(new_geom)])
1308 # apply scale to translation
1309 transform[:3, 3] *= scale
1310 # update scene with new transforms
1311 result.graph.update(
1312 frame_to=node, matrix=transform, geometry=geometry
1313 )
1315 # remove camera from copied
1316 result._camera = None
1318 return result
1320 def copy(self) -> "Scene":
1321 """
1322 Return a deep copy of the current scene
1324 Returns
1325 ----------
1326 copied : trimesh.Scene
1327 Copy of the current scene
1328 """
1329 # use the geometries copy method to
1330 # allow them to handle references to unpickle-able objects
1331 geometry = {n: g.copy() for n, g in self.geometry.items()}
1333 if not hasattr(self, "_camera") or self._camera is None:
1334 # if no camera set don't include it
1335 camera = None
1336 else:
1337 # otherwise get a copy of the camera
1338 camera = self.camera.copy()
1339 # create a new scene with copied geometry and graph
1340 copied = Scene(
1341 geometry=geometry,
1342 graph=self.graph.copy(),
1343 metadata=self.metadata.copy(),
1344 camera=camera,
1345 )
1346 return copied
1348 def show(
1349 self,
1350 viewer: ViewerType = None,
1351 **kwargs,
1352 ):
1353 """
1354 Display the current scene.
1356 Parameters
1357 -----------
1358 viewer
1359 What kind of viewer to use, such as
1360 `gl` to open a pyglet window
1361 `jupyter` for a jupyter notebook
1362 `marimo'` for a marimo notebook
1363 None for a "best guess"
1364 kwargs
1365 Passed to viewer, such as `smooth=False` which will turn
1366 off automatic smooth shading
1367 """
1369 if viewer is None:
1370 # check to see if we are in a notebook or not
1371 from ..viewer import in_notebook
1373 # returns a literal for what kind of notebook, or False
1374 viewer = in_notebook()
1375 if not viewer:
1376 viewer = "gl"
1378 if viewer == "gl":
1379 # this imports pyglet, and will raise an ImportError
1380 # if pyglet is not available
1381 from ..viewer import SceneViewer
1383 return SceneViewer(self, **kwargs)
1384 elif viewer == "jupyter":
1385 from ..viewer import scene_to_notebook
1387 return scene_to_notebook(self, **kwargs)
1388 elif viewer == "marimo":
1389 from ..viewer import scene_to_mo_notebook
1391 return scene_to_mo_notebook(self, **kwargs)
1392 elif callable(viewer):
1393 # if a callable method like a custom class
1394 # constructor was passed run using that
1395 return viewer(self, **kwargs)
1396 else:
1397 raise ValueError(
1398 "Invalid value for viewer: not 'gl', 'jupyter', 'marimo', callable, or None"
1399 )
1401 def __add__(self, other):
1402 """
1403 Concatenate the current scene with another scene or mesh.
1405 Parameters
1406 ------------
1407 other : trimesh.Scene, trimesh.Trimesh, trimesh.Path
1408 Other object to append into the result scene
1410 Returns
1411 ------------
1412 appended : trimesh.Scene
1413 Scene with geometry from both scenes
1414 """
1415 result = append_scenes([self, other], common=[self.graph.base_frame])
1416 return result
1419def split_scene(geometry, **kwargs):
1420 """
1421 Given a geometry, list of geometries, or a Scene
1422 return them as a single Scene object.
1424 Parameters
1425 ----------
1426 geometry : splittable
1428 Returns
1429 ---------
1430 scene: trimesh.Scene
1431 """
1432 # already a scene, so return it
1433 if isinstance(geometry, Scene):
1434 return geometry
1436 # save metadata
1437 metadata = {}
1439 # a list of things
1440 if util.is_sequence(geometry):
1441 [metadata.update(getattr(g, "metadata", {})) for g in geometry]
1443 scene = Scene(geometry, metadata=metadata)
1444 scene._source = next((g.source for g in geometry if g.source is not None), None)
1445 else:
1446 # a single geometry so we are going to split
1447 scene = Scene(
1448 geometry.split(**kwargs),
1449 metadata=deepcopy(geometry.metadata),
1450 )
1451 scene._source = deepcopy(geometry.source)
1453 return scene
1456def append_scenes(iterable, common=None, base_frame="world"):
1457 """
1458 Concatenate multiple scene objects into one scene.
1460 Parameters
1461 -------------
1462 iterable : (n,) Trimesh or Scene
1463 Geometries that should be appended
1464 common : (n,) str
1465 Nodes that shouldn't be remapped
1466 base_frame : str
1467 Base frame of the resulting scene
1469 Returns
1470 ------------
1471 result : trimesh.Scene
1472 Scene containing all geometry
1473 """
1474 if isinstance(iterable, Scene):
1475 return iterable
1477 if common is None:
1478 common = [base_frame]
1480 # save geometry in dict
1481 geometry = {}
1482 # save transforms as edge tuples
1483 edges = []
1485 # nodes which shouldn't be remapped
1486 common = set(common)
1487 # nodes which are consumed and need to be remapped
1488 consumed = set()
1490 def node_remap(node):
1491 """
1492 Remap node to new name if necessary
1494 Parameters
1495 -------------
1496 node : hashable
1497 Node name in original scene
1499 Returns
1500 -------------
1501 name : hashable
1502 Node name in concatenated scene
1503 """
1505 # if we've already remapped a node use it
1506 if node in map_node:
1507 return map_node[node]
1509 # if a node is consumed and isn't one of the nodes
1510 # we're going to hold common between scenes remap it
1511 if node not in common and node in consumed:
1512 # generate a name not in consumed
1513 name = node + util.unique_id()
1514 map_node[node] = name
1515 node = name
1517 # keep track of which nodes have been used
1518 # in the current scene
1519 current.add(node)
1520 return node
1522 # loop through every geometry
1523 for s in iterable:
1524 # allow Trimesh/Path2D geometry to be passed
1525 if hasattr(s, "scene"):
1526 s = s.scene()
1527 # if we don't have a scene raise an exception
1528 if not isinstance(s, Scene):
1529 raise ValueError(f"{type(s).__name__} is not a scene!")
1531 # remap geometries if they have been consumed
1532 map_geom = {}
1533 for k, v in s.geometry.items():
1534 # if a geometry already exists add a UUID to the name
1535 name = unique_name(start=k, contains=geometry.keys())
1536 # store name mapping
1537 map_geom[k] = name
1538 # store geometry with new name
1539 geometry[name] = v
1541 # remap nodes and edges so duplicates won't
1542 # stomp all over each other
1543 map_node = {}
1544 # the nodes used in this scene
1545 current = set()
1546 for a, b, attr in s.graph.to_edgelist():
1547 # remap node names from local names
1548 a, b = node_remap(a), node_remap(b)
1549 # remap geometry keys
1550 # if key is not in map_geom it means one of the scenes
1551 # referred to geometry that doesn't exist
1552 # rather than crash here we ignore it as the user
1553 # possibly intended to add in geometries back later
1554 if "geometry" in attr and attr["geometry"] in map_geom:
1555 attr["geometry"] = map_geom[attr["geometry"]]
1556 # save the new edge
1557 edges.append((a, b, attr))
1558 # mark nodes from current scene as consumed
1559 consumed.update(current)
1561 # add all data to a new scene
1562 result = Scene(base_frame=base_frame)
1563 result.graph.from_edgelist(edges)
1564 result.geometry.update(geometry)
1566 return result
1569def reconstruct_instances(scene: Scene, cost_threshold: Floating = 1e-6) -> Scene:
1570 """
1571 If a scene has been "baked" with meshes it means that
1572 the duplicate nodes have *corresponding vertices* but are
1573 rigidly transformed to different places.
1575 This means the problem of finding ab instance transform can
1576 use the `procrustes` analysis which is *very* fast relative
1577 to more complicated registration problems that require ICP
1578 and nearest-point-on-surface calculations.
1580 TODO : construct a parent non-geometry node for containing every group.
1582 Parameters
1583 ----------
1584 scene
1585 The scene to handle.
1586 cost_threshold
1587 The maximum value for `procrustes` cost which is "squared mean
1588 vertex distance between pair". If the fit is above this value
1589 the instance will be left even if it is a duplicate.
1591 Returns
1592 ---------
1593 dedupe
1594 A copy of the scene de-duplicated as much as possible.
1595 """
1596 # start with the original scene graph and modify in-loop
1597 graph = scene.graph.copy()
1599 for group in scene.duplicate_nodes:
1600 # not sure if this ever includes
1601 if len(group) < 2:
1602 continue
1604 # we are going to use one of the geometries and try to register the others to it
1605 node_base = group[0]
1606 # get the geometry name for this base node
1607 _, geom_base = scene.graph[node_base]
1608 # get the vertices of the base model
1609 base = scene.geometry[geom_base].vertices.view(np.ndarray)
1611 for node in group[1:]:
1612 # the original pose of this node in the scene
1613 node_mat, node_geom = scene.graph[node]
1614 # procrustes matches corresponding point arrays very quickly
1615 # but we have to make sure that they actual correspond in shape
1616 node_vertices = scene.geometry[node_geom].vertices.view(np.ndarray)
1618 # procrustes only works on corresponding point clouds!
1619 if node_vertices.shape != base.shape:
1620 continue
1622 # solve for a pose moving this instance into position
1623 matrix, _p, cost = procrustes(
1624 base, node_vertices, translation=True, scale=False, reflection=False
1625 )
1626 if cost < cost_threshold:
1627 # add the transform we found
1628 graph.update(node, matrix=np.dot(node_mat, matrix), geometry=geom_base)
1630 # get from the new graph which geometry ends up with a reference
1631 referenced = set(graph.geometry_nodes.keys())
1633 # return a scene with the de-duplicated graph and a copy of any geometry
1634 return Scene(
1635 geometry={k: v.copy() for k, v in scene.geometry.items() if k in referenced},
1636 graph=graph,
1637 )