import collections
import uuid
import numpy as np
from .. import caching, convex, grouping, inertia, transformations, units, util
from ..constants import log
from ..exchange import export
from ..parent import Geometry, Geometry3D
from ..typed import (
ArrayLike,
Dict,
List,
NDArray,
Optional,
Sequence,
Tuple,
Union,
float64,
int64,
)
from ..util import unique_name
from . import cameras, lighting
from .transforms import SceneGraph
# the types of objects we can create a scene from
GeometryInput = Union[
Geometry, Sequence[Geometry], NDArray[Geometry], Dict[str, Geometry]
]
[docs]
class Scene(Geometry3D):
"""
A simple scene graph which can be rendered directly via
pyglet/openGL or through other endpoints such as a
raytracer. Meshes are added by name, which can then be
moved by updating transform in the transform tree.
"""
[docs]
def __init__(
self,
geometry: Optional[GeometryInput] = None,
base_frame: str = "world",
metadata: Optional[Dict] = None,
graph: Optional[SceneGraph] = None,
camera: Optional[cameras.Camera] = None,
lights: Optional[Sequence[lighting.Light]] = None,
camera_transform: Optional[NDArray] = None,
):
"""
Create a new Scene object.
Parameters
-------------
geometry : Trimesh, Path2D, Path3D PointCloud or list
Geometry to initially add to the scene
base_frame
Name of base frame
metadata
Any metadata about the scene
graph
A passed transform graph to use
camera : Camera or None
A passed camera to use
lights : [trimesh.scene.lighting.Light] or None
A passed lights to use
camera_transform
Homogeneous (4, 4) camera transform in the base frame
"""
# mesh name : Trimesh object
self.geometry = collections.OrderedDict()
# create a new graph
self.graph = SceneGraph(base_frame=base_frame)
# create our cache
self._cache = caching.Cache(id_function=self.__hash__)
if geometry is not None:
# add passed geometry to scene
self.add_geometry(geometry)
# hold metadata about the scene
self.metadata = {}
if isinstance(metadata, dict):
self.metadata.update(metadata)
if graph is not None:
# if we've been passed a graph override the default
self.graph = graph
if lights is not None:
self.lights = lights
if camera is not None:
self.camera = camera
if camera_transform is not None:
self.camera_transform = camera_transform
[docs]
def add_geometry(
self,
geometry: GeometryInput,
node_name: Optional[str] = None,
geom_name: Optional[str] = None,
parent_node_name: Optional[str] = None,
transform: Optional[NDArray] = None,
metadata: Optional[Dict] = None,
):
"""
Add a geometry to the scene.
If the mesh has multiple transforms defined in its
metadata, they will all be copied into the
TransformForest of the current scene automatically.
Parameters
----------
geometry : Trimesh, Path2D, Path3D PointCloud or list
Geometry to initially add to the scene
node_name : None or str
Name of the added node.
geom_name : None or str
Name of the added geometry.
parent_node_name : None or str
Name of the parent node in the graph.
transform : None or (4, 4) float
Transform that applies to the added node.
metadata : None or dict
Optional metadata for the node.
Returns
----------
node_name : str
Name of single node in self.graph (passed in) or None if
node was not added (eg. geometry was null or a Scene).
"""
if geometry is None:
return
# PointCloud objects will look like a sequence
elif util.is_sequence(geometry):
# if passed a sequence add all elements
return [
self.add_geometry(
geometry=value,
node_name=node_name,
geom_name=geom_name,
parent_node_name=parent_node_name,
transform=transform,
metadata=metadata,
)
for value in geometry
]
elif isinstance(geometry, dict):
# if someone passed us a dict of geometry
return {
k: self.add_geometry(geometry=v, geom_name=k, metadata=metadata)
for k, v in geometry.items()
}
elif isinstance(geometry, Scene):
# concatenate current scene with passed scene
concat = self + geometry
# replace geometry in-place
self.geometry.clear()
self.geometry.update(concat.geometry)
# replace graph data with concatenated graph
self.graph.transforms = concat.graph.transforms
return
if not hasattr(geometry, "vertices"):
util.log.debug(f"unknown type ({type(geometry).__name__}) added to scene!")
return
# get or create a name to reference the geometry by
if geom_name is not None:
# if name is passed use it
name = geom_name
elif "name" in geometry.metadata:
# if name is in metadata use it
name = geometry.metadata["name"]
elif "file_name" in geometry.metadata:
name = geometry.metadata["file_name"]
else:
# try to create a simple name
name = "geometry_" + str(len(self.geometry))
# if its already taken use our unique name logic
name = unique_name(start=name, contains=self.geometry.keys())
# save the geometry reference
self.geometry[name] = geometry
# create a unique node name if not passed
if node_name is None:
# if the name of the geometry is also a transform node
# which graph nodes already exist
existing = self.graph.transforms.node_data.keys()
# find a name that isn't contained already starting
# at the name we have
node_name = unique_name(name, existing)
assert node_name not in existing
if transform is None:
# create an identity transform from parent_node
transform = np.eye(4)
self.graph.update(
frame_to=node_name,
frame_from=parent_node_name,
matrix=transform,
geometry=name,
geometry_flags={"visible": True},
metadata=metadata,
)
return node_name
[docs]
def delete_geometry(self, names: Union[set, str, Sequence]) -> None:
"""
Delete one more multiple geometries from the scene and also
remove any node in the transform graph which references it.
Parameters
--------------
name : hashable
Name that references self.geometry
"""
# make sure we have a set we can check
if util.is_string(names):
names = [names]
names = set(names)
# remove the geometry reference from relevant nodes
self.graph.remove_geometries(names)
# remove the geometries from our geometry store
[self.geometry.pop(name, None) for name in names]
[docs]
def strip_visuals(self) -> None:
"""
Strip visuals from every Trimesh geometry
and set them to an empty `ColorVisuals`.
"""
from ..visual.color import ColorVisuals
for geometry in self.geometry.values():
if util.is_instance_named(geometry, "Trimesh"):
geometry.visual = ColorVisuals(mesh=geometry)
[docs]
def __hash__(self) -> int:
"""
Return information about scene which is hashable.
Returns
---------
hashed
String hashing scene.
"""
# avoid accessing attribute in tight loop
geometry = self.geometry
# hash of geometry and transforms
# start with the last modified time of the scene graph
hashable = [hex(self.graph.transforms.__hash__())]
# take the re-hex string of the hash
hashable.extend(hex(geometry[k].__hash__()) for k in geometry.keys())
return caching.hash_fast("".join(hashable).encode("utf-8"))
@property
def is_empty(self) -> bool:
"""
Does the scene have anything in it.
Returns
----------
is_empty
True if nothing is in the scene
"""
return len(self.geometry) == 0
@property
def is_valid(self) -> bool:
"""
Is every geometry connected to the root node.
Returns
-----------
is_valid : bool
Does every geometry have a transform
"""
if len(self.geometry) == 0:
return True
try:
referenced = {self.graph[i][1] for i in self.graph.nodes_geometry}
except BaseException:
# if connectivity to world frame is broken return false
return False
# every geometry is referenced
return referenced == set(self.geometry.keys())
@caching.cache_decorator
def bounds_corners(self) -> Dict[str, NDArray[float64]]:
"""
Get the post-transform AABB for each node
which has geometry defined.
Returns
-----------
corners
Bounds for each node with vertices:
{node_name : (2, 3) float}
"""
# collect AABB for each geometry
corners = {}
# collect vertices for every mesh
vertices = {
k: m.vertices
for k, m in self.geometry.items()
if hasattr(m, "vertices") and len(m.vertices) > 0
}
# handle 2D geometries
vertices.update(
{
k: np.column_stack((v, np.zeros(len(v))))
for k, v in vertices.items()
if v.shape[1] == 2
}
)
# loop through every node with geometry
for node_name in self.graph.nodes_geometry:
# access the transform and geometry name from node
transform, geometry_name = self.graph[node_name]
# will be None if no vertices for this node
points = vertices.get(geometry_name)
# skip empty geometries
if points is None:
continue
# apply just the rotation to skip N multiplies
dot = np.dot(transform[:3, :3], points.T)
# append the AABB with translation applied after
corners[node_name] = np.array(
[dot.min(axis=1) + transform[:3, 3], dot.max(axis=1) + transform[:3, 3]]
)
return corners
@caching.cache_decorator
def bounds(self) -> Optional[NDArray[float64]]:
"""
Return the overall bounding box of the scene.
Returns
--------
bounds : (2, 3) float or None
Position of [min, max] bounding box
Returns None if no valid bounds exist
"""
bounds_corners = self.bounds_corners
if len(bounds_corners) == 0:
return None
# combine each geometry node AABB into a larger list
corners = np.vstack(list(self.bounds_corners.values()))
return np.array([corners.min(axis=0), corners.max(axis=0)], dtype=np.float64)
@caching.cache_decorator
def extents(self) -> Optional[NDArray[float64]]:
"""
Return the axis aligned box size of the current scene
or None if the scene is empty.
Returns
----------
extents
Bounding box sides length or None for empty scene.
"""
bounds = self.bounds
if bounds is None:
return None
return np.diff(bounds, axis=0).reshape(-1)
@caching.cache_decorator
def scale(self) -> float:
"""
The approximate scale of the mesh
Returns
-----------
scale : float
The mean of the bounding box edge lengths
"""
extents = self.extents
if extents is None:
return 1.0
return float((extents**2).sum() ** 0.5)
@caching.cache_decorator
def centroid(self) -> Optional[NDArray[float64]]:
"""
Return the center of the bounding box for the scene.
Returns
--------
centroid : (3) float
Point for center of bounding box
"""
bounds = self.bounds
if bounds is None:
return None
centroid = np.mean(self.bounds, axis=0)
return centroid
@caching.cache_decorator
def center_mass(self) -> NDArray:
"""
Find the center of mass for every instance in the scene.
Returns
------------
center_mass : (3,) float
The center of mass of the scene
"""
# get the center of mass and volume for each geometry
center_mass = {
k: m.center_mass
for k, m in self.geometry.items()
if hasattr(m, "center_mass")
}
mass = {k: m.mass for k, m in self.geometry.items() if hasattr(m, "mass")}
# get the geometry name and transform for each instance
graph = self.graph
instance = [graph[n] for n in graph.nodes_geometry]
# get the transformed center of mass for each instance
transformed = np.array(
[
np.dot(mat, np.append(center_mass[g], 1))[:3]
for mat, g in instance
if g in center_mass
],
dtype=np.float64,
)
# weight the center of mass locations by volume
weights = np.array([mass[g] for _, g in instance], dtype=np.float64)
weights /= weights.sum()
return (transformed * weights.reshape((-1, 1))).sum(axis=0)
@caching.cache_decorator
def moment_inertia(self):
"""
Return the moment of inertia of the current scene with
respect to the center of mass of the current scene.
Returns
------------
inertia : (3, 3) float
Inertia with respect to cartesian axis at `scene.center_mass`
"""
return inertia.scene_inertia(
scene=self, transform=transformations.translation_matrix(self.center_mass)
)
[docs]
def moment_inertia_frame(self, transform):
"""
Return the moment of inertia of the current scene relative
to a transform from the base frame.
Parameters
transform : (4, 4) float
Homogeneous transformation matrix.
Returns
-------------
inertia : (3, 3) float
Inertia tensor at requested frame.
"""
return inertia.scene_inertia(scene=self, transform=transform)
@caching.cache_decorator
def area(self) -> float:
"""
What is the summed area of every geometry which
has area.
Returns
------------
area : float
Summed area of every instanced geometry
"""
# get the area of every geometry that has an area property
areas = {n: g.area for n, g in self.geometry.items() if hasattr(g, "area")}
# sum the area including instancing
return sum(
(areas.get(self.graph[n][1], 0.0) for n in self.graph.nodes_geometry), 0.0
)
@caching.cache_decorator
def volume(self) -> float64:
"""
What is the summed volume of every geometry which
has volume
Returns
------------
volume : float
Summed area of every instanced geometry
"""
# get the area of every geometry that has a volume attribute
volume = {n: g.volume for n, g in self.geometry.items() if hasattr(g, "area")}
# sum the area including instancing
return sum(
(volume.get(self.graph[n][1], 0.0) for n in self.graph.nodes_geometry), 0.0
)
@caching.cache_decorator
def triangles(self) -> NDArray[float64]:
"""
Return a correctly transformed polygon soup of the
current scene.
Returns
----------
triangles : (n, 3, 3) float
Triangles in space
"""
triangles = []
triangles_node = []
for node_name in self.graph.nodes_geometry:
# which geometry does this node refer to
transform, geometry_name = self.graph[node_name]
# get the actual potential mesh instance
geometry = self.geometry[geometry_name]
if not hasattr(geometry, "triangles"):
continue
# append the (n, 3, 3) triangles to a sequence
triangles.append(
transformations.transform_points(
geometry.triangles.copy().reshape((-1, 3)), matrix=transform
)
)
# save the node names for each triangle
triangles_node.append(np.tile(node_name, len(geometry.triangles)))
# save the resulting nodes to the cache
self._cache["triangles_node"] = np.hstack(triangles_node)
return np.vstack(triangles).reshape((-1, 3, 3))
@caching.cache_decorator
def triangles_node(self):
"""
Which node of self.graph does each triangle come from.
Returns
---------
triangles_index : (len(self.triangles),)
Node name for each triangle
"""
populate = self.triangles # NOQA
return self._cache["triangles_node"]
@caching.cache_decorator
def geometry_identifiers(self) -> Dict[str, str]:
"""
Look up geometries by identifier hash.
Returns
---------
identifiers
{Identifier hash: key in self.geometry}
"""
identifiers = {mesh.identifier_hash: name for name, mesh in self.geometry.items()}
return identifiers
@caching.cache_decorator
def duplicate_nodes(self) -> List[List[str]]:
"""
Return a sequence of node keys of identical meshes.
Will include meshes with different geometry but identical
spatial hashes as well as meshes repeated by self.nodes.
Returns
-----------
duplicates
Keys of self.graph that represent identical geometry
"""
# if there is no geometry we can have no duplicate nodes
if len(self.geometry) == 0:
return []
# geometry name : hash of mesh
hashes = {
k: int(m.identifier_hash, 16)
for k, m in self.geometry.items()
if hasattr(m, "identifier_hash")
}
# bring into local scope for loop
graph = self.graph
# get a hash for each node name
# scene.graph node name : hashed geometry
node_hash = {node: hashes.get(graph[node][1]) for node in graph.nodes_geometry}
# collect node names for each hash key
duplicates = collections.defaultdict(list)
# use a slightly off-label list comprehension
# for debatable function call overhead avoidance
[
duplicates[hashed].append(node)
for node, hashed in node_hash.items()
if hashed is not None
]
# we only care about the values keys are garbage
return list(duplicates.values())
[docs]
def deduplicated(self) -> "Scene":
"""
Return a new scene where each unique geometry is only
included once and transforms are discarded.
Returns
-------------
dedupe : Scene
One copy of each unique geometry from scene
"""
# collect geometry
geometry = {}
# loop through groups of identical nodes
for group in self.duplicate_nodes:
# get the name of the geometry
name = self.graph[group[0]][1]
# collect our unique collection of geometry
geometry[name] = self.geometry[name]
return Scene(geometry)
[docs]
def set_camera(
self, angles=None, distance=None, center=None, resolution=None, fov=None
) -> cameras.Camera:
"""
Create a camera object for self.camera, and add
a transform to self.graph for it.
If arguments are not passed sane defaults will be figured
out which show the mesh roughly centered.
Parameters
-----------
angles : (3,) float
Initial euler angles in radians
distance : float
Distance from centroid
center : (3,) float
Point camera should be center on
camera : Camera object
Object that stores camera parameters
"""
if fov is None:
fov = np.array([60, 45])
# if no geometry nothing to set camera to
if len(self.geometry) == 0:
self._camera = cameras.Camera(fov=fov)
self.graph[self._camera.name] = np.eye(4)
return self._camera
# set with no rotation by default
if angles is None:
angles = np.zeros(3)
rotation = transformations.euler_matrix(*angles)
transform = cameras.look_at(
self.bounds, fov=fov, rotation=rotation, distance=distance, center=center
)
if hasattr(self, "_camera") and self._camera is not None:
self._camera.fov = fov
if resolution is not None:
self._camera.resolution = resolution
else:
# create a new camera object
self._camera = cameras.Camera(fov=fov, resolution=resolution)
self.graph[self._camera.name] = transform
return self._camera
@property
def camera_transform(self):
"""
Get camera transform in the base frame.
Returns
-------
camera_transform : (4, 4) float
Camera transform in the base frame
"""
return self.graph[self.camera.name][0]
@camera_transform.setter
def camera_transform(self, matrix: ArrayLike):
"""
Set the camera transform in the base frame
Parameters
----------
camera_transform : (4, 4) float
Camera transform in the base frame
"""
self.graph[self.camera.name] = matrix
[docs]
def camera_rays(self) -> Tuple[NDArray[float64], NDArray[float64], NDArray[int64]]:
"""
Calculate the trimesh.scene.Camera origin and ray
direction vectors. Returns one ray per pixel as set
in camera.resolution
Returns
--------------
origin: (n, 3) float
Ray origins in space
vectors: (n, 3) float
Ray direction unit vectors in world coordinates
pixels : (n, 2) int
Which pixel does each ray correspond to in an image
"""
# get the unit vectors of the camera
vectors, pixels = self.camera.to_rays()
# find our scene's transform for the camera
transform = self.camera_transform
# apply the rotation to the unit ray direction vectors
vectors = transformations.transform_points(vectors, transform, translate=False)
# camera origin is single point so extract from
origins = np.ones_like(vectors) * transformations.translation_from_matrix(
transform
)
return origins, vectors, pixels
@property
def camera(self) -> cameras.Camera:
"""
Get the single camera for the scene. If not manually
set one will abe automatically generated.
Returns
----------
camera : trimesh.scene.Camera
Camera object defined for the scene
"""
# no camera set for the scene yet
if not self.has_camera:
# will create a camera with everything in view
return self.set_camera()
assert self._camera is not None
return self._camera
@camera.setter
def camera(self, camera: Optional[cameras.Camera]):
"""
Set a camera object for the Scene.
Parameters
-----------
camera : trimesh.scene.Camera
Camera object for the scene
"""
if camera is None:
return
self._camera = camera
@property
def has_camera(self) -> bool:
return hasattr(self, "_camera") and self._camera is not None
@property
def lights(self) -> List[lighting.Light]:
"""
Get a list of the lights in the scene. If nothing is
set it will generate some automatically.
Returns
-------------
lights : [trimesh.scene.lighting.Light]
Lights in the scene.
"""
if not hasattr(self, "_lights") or self._lights is None:
# do some automatic lighting
lights, transforms = lighting.autolight(self)
# assign the transforms to the scene graph
for L, T in zip(lights, transforms):
self.graph[L.name] = T
# set the lights
self._lights = lights
return self._lights
@lights.setter
def lights(self, lights: Sequence[lighting.Light]):
"""
Assign a list of light objects to the scene
Parameters
--------------
lights : [trimesh.scene.lighting.Light]
Lights in the scene.
"""
self._lights = lights
[docs]
def rezero(self) -> None:
"""
Move the current scene so that the AABB of the whole
scene is centered at the origin.
Does this by changing the base frame to a new, offset
base frame.
"""
if self.is_empty or np.allclose(self.centroid, 0.0):
# early exit since what we want already exists
return
# the transformation to move the overall scene to AABB centroid
matrix = np.eye(4)
matrix[:3, 3] = -self.centroid
# we are going to change the base frame
new_base = str(self.graph.base_frame) + "_I"
self.graph.update(
frame_from=new_base, frame_to=self.graph.base_frame, matrix=matrix
)
self.graph.base_frame = new_base
[docs]
def dump(self, concatenate: bool = False) -> Union[Geometry, List[Geometry]]:
"""
Append all meshes in scene freezing transforms.
Parameters
------------
concatenate
If True, concatenate results into single mesh
Returns
----------
dumped : (n,) Trimesh, Path2D, Path3D, PointCloud
Depending on what the scene contains. If `concatenate`
then some geometry may be dropped if it doesn't match.
"""
result = []
for node_name in self.graph.nodes_geometry:
transform, geometry_name = self.graph[node_name]
# get a copy of the geometry
current = self.geometry[geometry_name].copy()
# if the geometry is 2D see if we have to upgrade to 3D
if hasattr(current, "to_3D"):
# check to see if the scene is transforming the path out of plane
check = util.isclose(transform, util._IDENTITY, atol=1e-8)
check[:2, :3] = True
if not check.all():
# transform moves in 3D so we put this on the Z=0 plane
current = current.to_3D()
else:
# transform moves in 2D so clip off the last row and column
transform = transform[:3, :3]
# move the geometry vertices into the requested frame
current.apply_transform(transform)
current.metadata["name"] = geometry_name
current.metadata["node"] = node_name
# save to our list of meshes
result.append(current)
if concatenate:
# if scene has mixed geometry this may drop some of it
return util.concatenate(result)
return result
[docs]
def subscene(self, node: str) -> "Scene":
"""
Get part of a scene that succeeds a specified node.
Parameters
------------
node
Hashable key in `scene.graph`
Returns
-----------
subscene
Partial scene generated from current.
"""
# get every node that is a successor to specified node
# this includes `node`
graph = self.graph
nodes = graph.transforms.successors(node)
# get every edge that has an included node
edges = [e for e in graph.to_edgelist() if e[0] in nodes]
# create a scene graph when
graph = SceneGraph(base_frame=node)
graph.from_edgelist(edges)
geometry_names = {e[2]["geometry"] for e in edges if "geometry" in e[2]}
geometry = {k: self.geometry[k] for k in geometry_names}
result = Scene(geometry=geometry, graph=graph)
return result
@caching.cache_decorator
def convex_hull(self):
"""
The convex hull of the whole scene.
Returns
---------
hull : trimesh.Trimesh
Trimesh object which is a convex hull of all meshes in scene
"""
points = util.vstack_empty([m.vertices for m in self.dump()])
return convex.convex_hull(points)
[docs]
def export(self, file_obj=None, file_type=None, **kwargs):
"""
Export a snapshot of the current scene.
Parameters
----------
file_obj : str, file-like, or None
File object to export to
file_type : str or None
What encoding to use for meshes
IE: dict, dict64, stl
Returns
----------
export : bytes
Only returned if file_obj is None
"""
return export.export_scene(
scene=self, file_obj=file_obj, file_type=file_type, **kwargs
)
[docs]
def save_image(self, resolution=None, **kwargs) -> bytes:
"""
Get a PNG image of a scene.
Parameters
-----------
resolution : (2,) int
Resolution to render image
**kwargs
Passed to SceneViewer constructor
Returns
-----------
png : bytes
Render of scene as a PNG
"""
from ..viewer.windowed import render_scene
return render_scene(scene=self, resolution=resolution, **kwargs)
@property
def units(self) -> Optional[str]:
"""
Get the units for every model in the scene. If the scene has
mixed units or no units this will return None.
Returns
-----------
units
Units for every model in the scene or None
if there are no units or mixed units
"""
# get a set of the units of every geometry
existing = {i.units for i in self.geometry.values()}
if len(existing) == 1:
return existing.pop()
elif len(existing) > 1:
log.warning(f"Mixed units `{existing}` returning None")
return None
@units.setter
def units(self, value: str):
"""
Set the units for every model in the scene without
converting any units just setting the tag.
Parameters
------------
value : str
Value to set every geometry unit value to
"""
value = value.strip().lower()
for m in self.geometry.values():
m.units = value
[docs]
def convert_units(self, desired: str, guess: bool = False) -> "Scene":
"""
If geometry has units defined convert them to new units.
Returns a new scene with geometries and transforms scaled.
Parameters
----------
desired : str
Desired final unit system: 'inches', 'mm', etc.
guess : bool
Is the converter allowed to guess scale when models
don't have it specified in their metadata.
Returns
----------
scaled : trimesh.Scene
Copy of scene with scaling applied and units set
for every model
"""
# if there is no geometry do nothing
if len(self.geometry) == 0:
return self.copy()
current = self.units
if current is None:
# will raise ValueError if not in metadata
# and not allowed to guess
current = units.units_from_metadata(self, guess=guess)
# find the float conversion
scale = units.unit_conversion(current=current, desired=desired)
# exit early if our current units are the same as desired units
if np.isclose(scale, 1.0):
result = self.copy()
else:
result = self.scaled(scale=scale)
# apply the units to every geometry of the scaled result
result.units = desired
return result
[docs]
def explode(self, vector=None, origin=None) -> None:
"""
Explode the current scene in-place around a point and vector.
Parameters
-----------
vector : (3,) float or float
Explode radially around a direction vector or spherically
origin : (3,) float
Point to explode around
"""
if origin is None:
origin = self.centroid
if vector is None:
vector = self.scale / 25.0
vector = np.asanyarray(vector, dtype=np.float64)
origin = np.asanyarray(origin, dtype=np.float64)
for node_name in self.graph.nodes_geometry:
transform, geometry_name = self.graph[node_name]
centroid = self.geometry[geometry_name].centroid
# transform centroid into nodes location
centroid = np.dot(transform, np.append(centroid, 1))[:3]
if vector.shape == ():
# case where our vector is a single number
offset = (centroid - origin) * vector
elif np.shape(vector) == (3,):
projected = np.dot(vector, (centroid - origin))
offset = vector * projected
else:
raise ValueError("explode vector wrong shape!")
# original transform is read-only
T_new = transform.copy()
T_new[:3, 3] += offset
self.graph[node_name] = T_new
[docs]
def scaled(self, scale: Union[float, ArrayLike]) -> "Scene":
"""
Return a copy of the current scene, with meshes and scene
transforms scaled to the requested factor.
Parameters
-----------
scale : float or (3,) float
Factor to scale meshes and transforms
Returns
-----------
scaled : trimesh.Scene
A copy of the current scene but scaled
"""
# convert 2D geometries to 3D for 3D scaling factors
scale_is_3D = isinstance(scale, (list, tuple, np.ndarray)) and len(scale) == 3
if scale_is_3D and np.all(np.asarray(scale) == scale[0]):
# scale is uniform
scale = float(scale[0])
scale_is_3D = False
elif not scale_is_3D:
scale = float(scale)
# result is a copy
result = self.copy()
if scale_is_3D:
# Copy all geometries that appear multiple times in the scene,
# such that no two nodes share the same geometry.
# This is required since the non-uniform scaling will most likely
# affect the same geometry in different poses differently.
# Note, that this is not needed in the case of uniform scaling.
for geom_name in result.graph.geometry_nodes:
nodes_with_geom = result.graph.geometry_nodes[geom_name]
if len(nodes_with_geom) > 1:
geom = result.geometry[geom_name]
for n in nodes_with_geom:
p = result.graph.transforms.parents[n]
result.add_geometry(
geometry=geom.copy(),
geom_name=geom_name,
node_name=n,
parent_node_name=p,
transform=result.graph.transforms.edge_data[(p, n)].get(
"matrix", None
),
metadata=result.graph.transforms.edge_data[(p, n)].get(
"metadata", None
),
)
result.delete_geometry(geom_name)
# Convert all 2D paths to 3D paths
for geom_name in result.geometry:
if result.geometry[geom_name].vertices.shape[1] == 2:
result.geometry[geom_name] = result.geometry[geom_name].to_3D()
# Scale all geometries by un-doing their local rotations first
for key in result.graph.nodes_geometry:
T, geom_name = result.graph.get(key)
# transform from graph should be read-only
T = T.copy()
T[:3, 3] = 0.0
# Get geometry transform w.r.t. base frame
result.geometry[geom_name].apply_transform(T).apply_scale(
scale
).apply_transform(np.linalg.inv(T))
# Scale all transformations in the scene graph
edge_data = result.graph.transforms.edge_data
for uv in edge_data:
if "matrix" in edge_data[uv]:
props = edge_data[uv]
T = edge_data[uv]["matrix"].copy()
T[:3, 3] *= scale
props["matrix"] = T
result.graph.update(frame_from=uv[0], frame_to=uv[1], **props)
# Clear cache
result.graph.transforms._cache = {}
result.graph.transforms._modified = str(uuid.uuid4())
result.graph._cache.clear()
else:
# matrix for 2D scaling
scale_2D = np.eye(3) * scale
# matrix for 3D scaling
scale_3D = np.eye(4) * scale
# preallocate transforms and geometries
nodes = np.array(self.graph.nodes_geometry)
transforms = np.zeros((len(nodes), 4, 4))
geometries = [None] * len(nodes)
# collect list of transforms
for i, node in enumerate(nodes):
transforms[i], geometries[i] = self.graph[node]
# remove all existing transforms
result.graph.clear()
for group in grouping.group(geometries):
# hashable reference to self.geometry
geometry = geometries[group[0]]
# original transform from world to geometry
original = transforms[group[0]]
# transform for geometry
new_geom = np.dot(scale_3D, original)
if result.geometry[geometry].vertices.shape[1] == 2:
# if our scene is 2D only scale in 2D
result.geometry[geometry].apply_transform(scale_2D)
else:
# otherwise apply the full transform
result.geometry[geometry].apply_transform(new_geom)
for node, T in zip(nodes[group], transforms[group]):
# generate the new transforms
transform = util.multi_dot([scale_3D, T, np.linalg.inv(new_geom)])
# apply scale to translation
transform[:3, 3] *= scale
# update scene with new transforms
result.graph.update(
frame_to=node, matrix=transform, geometry=geometry
)
return result
[docs]
def copy(self) -> "Scene":
"""
Return a deep copy of the current scene
Returns
----------
copied : trimesh.Scene
Copy of the current scene
"""
# use the geometries copy method to
# allow them to handle references to unpickle-able objects
geometry = {n: g.copy() for n, g in self.geometry.items()}
if not hasattr(self, "_camera") or self._camera is None:
# if no camera set don't include it
camera = None
else:
# otherwise get a copy of the camera
camera = self.camera.copy()
# create a new scene with copied geometry and graph
copied = Scene(
geometry=geometry,
graph=self.graph.copy(),
metadata=self.metadata.copy(),
camera=camera,
)
return copied
[docs]
def show(self, viewer=None, **kwargs):
"""
Display the current scene.
Parameters
-----------
viewer : Union[str, callable, None]
What kind of viewer to use, such as
'gl' to open a pyglet window, 'notebook'
for a jupyter notebook or None
kwargs : dict
Includes `smooth`, which will turn
on or off automatic smooth shading
"""
if viewer is None:
# check to see if we are in a notebook or not
from ..viewer import in_notebook
if in_notebook():
viewer = "notebook"
else:
viewer = "gl"
if viewer == "gl":
# this imports pyglet, and will raise an ImportError
# if pyglet is not available
from ..viewer import SceneViewer
return SceneViewer(self, **kwargs)
elif viewer == "notebook":
from ..viewer import scene_to_notebook
return scene_to_notebook(self, **kwargs)
elif callable(viewer):
# if a callable method like a custom class
# constructor was passed run using that
return viewer(self, **kwargs)
else:
raise ValueError('viewer must be "gl", "notebook", callable, or None')
[docs]
def __add__(self, other):
"""
Concatenate the current scene with another scene or mesh.
Parameters
------------
other : trimesh.Scene, trimesh.Trimesh, trimesh.Path
Other object to append into the result scene
Returns
------------
appended : trimesh.Scene
Scene with geometry from both scenes
"""
result = append_scenes([self, other], common=[self.graph.base_frame])
return result
def split_scene(geometry, **kwargs):
"""
Given a geometry, list of geometries, or a Scene
return them as a single Scene object.
Parameters
----------
geometry : splittable
Returns
---------
scene: trimesh.Scene
"""
# already a scene, so return it
if util.is_instance_named(geometry, "Scene"):
return geometry
# a list of things
if util.is_sequence(geometry):
metadata = {}
for g in geometry:
try:
metadata.update(g.metadata)
except BaseException:
continue
return Scene(geometry, metadata=metadata)
# a single geometry so we are going to split
split = []
metadata = {}
for g in util.make_sequence(geometry):
split.extend(g.split(**kwargs))
metadata.update(g.metadata)
# if there is only one geometry in the mesh
# name it from the file name
if len(split) == 1 and "file_name" in metadata:
split = {metadata["file_name"]: split[0]}
scene = Scene(split, metadata=metadata)
return scene
def append_scenes(iterable, common=None, base_frame="world"):
"""
Concatenate multiple scene objects into one scene.
Parameters
-------------
iterable : (n,) Trimesh or Scene
Geometries that should be appended
common : (n,) str
Nodes that shouldn't be remapped
base_frame : str
Base frame of the resulting scene
Returns
------------
result : trimesh.Scene
Scene containing all geometry
"""
if isinstance(iterable, Scene):
return iterable
if common is None:
common = [base_frame]
# save geometry in dict
geometry = {}
# save transforms as edge tuples
edges = []
# nodes which shouldn't be remapped
common = set(common)
# nodes which are consumed and need to be remapped
consumed = set()
def node_remap(node):
"""
Remap node to new name if necessary
Parameters
-------------
node : hashable
Node name in original scene
Returns
-------------
name : hashable
Node name in concatenated scene
"""
# if we've already remapped a node use it
if node in map_node:
return map_node[node]
# if a node is consumed and isn't one of the nodes
# we're going to hold common between scenes remap it
if node not in common and node in consumed:
# generate a name not in consumed
name = node + util.unique_id()
map_node[node] = name
node = name
# keep track of which nodes have been used
# in the current scene
current.add(node)
return node
# loop through every geometry
for s in iterable:
# allow Trimesh/Path2D geometry to be passed
if hasattr(s, "scene"):
s = s.scene()
# if we don't have a scene raise an exception
if not isinstance(s, Scene):
raise ValueError(f"{type(s).__name__} is not a scene!")
# remap geometries if they have been consumed
map_geom = {}
for k, v in s.geometry.items():
# if a geometry already exists add a UUID to the name
name = unique_name(start=k, contains=geometry.keys())
# store name mapping
map_geom[k] = name
# store geometry with new name
geometry[name] = v
# remap nodes and edges so duplicates won't
# stomp all over each other
map_node = {}
# the nodes used in this scene
current = set()
for a, b, attr in s.graph.to_edgelist():
# remap node names from local names
a, b = node_remap(a), node_remap(b)
# remap geometry keys
# if key is not in map_geom it means one of the scenes
# referred to geometry that doesn't exist
# rather than crash here we ignore it as the user
# possibly intended to add in geometries back later
if "geometry" in attr and attr["geometry"] in map_geom:
attr["geometry"] = map_geom[attr["geometry"]]
# save the new edge
edges.append((a, b, attr))
# mark nodes from current scene as consumed
consumed.update(current)
# add all data to a new scene
result = Scene(base_frame=base_frame)
result.graph.from_edgelist(edges)
result.geometry.update(geometry)
return result