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