1import os
2import numpy as np
3import collections
4
5from .. import util
6from .. import units
7from .. import convex
8from .. import caching
9from .. import grouping
10from .. import transformations
11
12from .. import bounds as bounds_module
13
14from ..exchange import gltf
15from ..parent import Geometry
16
17from . import cameras
18from . import lighting
19
20from .transforms import TransformForest
21
22
23class Scene(Geometry):
24    """
25    A simple scene graph which can be rendered directly via
26    pyglet/openGL or through other endpoints such as a
27    raytracer. Meshes are added by name, which can then be
28    moved by updating transform in the transform tree.
29    """
30
31    def __init__(self,
32                 geometry=None,
33                 base_frame='world',
34                 metadata={},
35                 graph=None,
36                 camera=None,
37                 lights=None,
38                 camera_transform=None):
39        """
40        Create a new Scene object.
41
42        Parameters
43        -------------
44        geometry : Trimesh, Path2D, Path3D PointCloud or list
45          Geometry to initially add to the scene
46        base_frame : str or hashable
47          Name of base frame
48        metadata : dict
49          Any metadata about the scene
50        graph : TransformForest or None
51          A passed transform graph to use
52        camera : Camera or None
53          A passed camera to use
54        lights : [trimesh.scene.lighting.Light] or None
55          A passed lights to use
56        camera_transform : (4, 4) float or None
57          Camera transform in the base frame
58        """
59        # mesh name : Trimesh object
60        self.geometry = collections.OrderedDict()
61
62        # create a new graph
63        self.graph = TransformForest(base_frame=base_frame)
64
65        # create our cache
66        self._cache = caching.Cache(id_function=self.md5)
67
68        # add passed geometry to scene
69        self.add_geometry(geometry)
70
71        # hold metadata about the scene
72        self.metadata = {}
73        self.metadata.update(metadata)
74
75        if graph is not None:
76            # if we've been passed a graph override the default
77            self.graph = graph
78
79        self.camera = camera
80        self.lights = lights
81        self.camera_transform = camera_transform
82
83    def apply_transform(self, transform):
84        """
85        Apply a transform to every geometry in the scene.
86
87        Parameters
88        --------------
89        transform : (4, 4)
90          Homogeneous transformation matrix
91        """
92        for geometry in self.geometry.values():
93            geometry.apply_transform(transform)
94
95    def add_geometry(self,
96                     geometry,
97                     node_name=None,
98                     geom_name=None,
99                     parent_node_name=None,
100                     transform=None):
101        """
102        Add a geometry to the scene.
103
104        If the mesh has multiple transforms defined in its
105        metadata, they will all be copied into the
106        TransformForest of the current scene automatically.
107
108        Parameters
109        ----------
110        geometry : Trimesh, Path2D, Path3D PointCloud or list
111          Geometry to initially add to the scene
112        base_frame : str or hashable
113          Name of base frame
114        metadata : dict
115          Any metadata about the scene
116        graph : TransformForest or None
117          A passed transform graph to use
118
119        Returns
120        ----------
121        node_name : str
122          Name of node in self.graph
123        """
124
125        if geometry is None:
126            return
127        # PointCloud objects will look like a sequence
128        elif util.is_sequence(geometry):
129            # if passed a sequence add all elements
130            for i, value in enumerate(geometry):
131                if i == 0:
132                    node_name = self.add_geometry(
133                        geometry=value,
134                        node_name=node_name,
135                        geom_name=geom_name,
136                        parent_node_name=parent_node_name,
137                        transform=transform)
138                else:
139                    self.add_geometry(
140                        geometry=value,
141                        geom_name=geom_name,
142                        parent_node_name=node_name)
143            return
144
145        elif isinstance(geometry, dict):
146            # if someone passed us a dict of geometry
147            for key, value in geometry.items():
148                self.add_geometry(value, geom_name=key)
149            return
150        elif isinstance(geometry, Scene):
151            # concatenate current scene with passed scene
152            concat = self + geometry
153            # replace geometry in-place
154            self.geometry.clear()
155            self.geometry.update(concat.geometry)
156            # replace graph data with concatenated graph
157            self.graph.transforms = concat.graph.transforms
158            return
159        elif not hasattr(geometry, 'vertices'):
160            util.log.warning('unknown type ({}) added to scene!'.format(
161                type(geometry).__name__))
162
163        # get or create a name to reference the geometry by
164        if geom_name is not None:
165            # if name is passed use it
166            name = geom_name
167        elif 'name' in geometry.metadata:
168            # if name is in metadata use it
169            name = geometry.metadata['name']
170        elif 'file_name' in geometry.metadata:
171            name = geometry.metadata['file_name']
172        else:
173            # try to create a simple name
174            name = 'geometry_' + str(len(self.geometry))
175
176        # if its already taken add a unique random string to it
177        if name in self.geometry:
178            name += ':' + util.unique_id().upper()
179
180        # save the geometry reference
181        self.geometry[name] = geometry
182
183        # create a unique node name if not passed
184        if node_name is None:
185            # a random unique identifier
186            unique = util.unique_id(increment=len(self.geometry))
187            # geometry name + UUID
188            node_name = name + '_' + unique.upper()
189
190        if transform is None:
191            # create an identity transform from parent_node
192            transform = np.eye(4)
193
194        self.graph.update(frame_to=node_name,
195                          frame_from=parent_node_name,
196                          matrix=transform,
197                          geometry=name,
198                          geometry_flags={'visible': True})
199        return node_name
200
201    def delete_geometry(self, names):
202        """
203        Delete one more multiple geometries from the scene and also
204        remove any node in the transform graph which references it.
205
206        Parameters
207        --------------
208        name : hashable
209          Name that references self.geometry
210        """
211        # make sure we have a set we can check
212        if util.is_string(names):
213            names = [names]
214        names = set(names)
215
216        # remove the geometry reference from relevant nodes
217        self.graph.remove_geometries(names)
218        # remove the geometries from our geometry store
219        [self.geometry.pop(name, None) for name in names]
220
221    def md5(self):
222        """
223        MD5 of scene which will change when meshes or
224        transforms are changed
225
226        Returns
227        --------
228        hashed : str
229          MD5 hash of scene
230        """
231        # start with transforms hash
232        hashes = [self.graph.md5()]
233        for g in self.geometry.values():
234            if hasattr(g, 'md5'):
235                hashes.append(g.md5())
236            elif hasattr(g, 'tostring'):
237                hashes.append(str(hash(g.tostring())))
238            else:
239                # try to just straight up hash
240                # this may raise errors
241                hashes.append(str(hash(g)))
242
243        md5 = util.md5_object(''.join(hashes))
244
245        return md5
246
247    @property
248    def is_empty(self):
249        """
250        Does the scene have anything in it.
251
252        Returns
253        ----------
254        is_empty: bool, True if nothing is in the scene
255        """
256
257        is_empty = len(self.geometry) == 0
258        return is_empty
259
260    @property
261    def is_valid(self):
262        """
263        Is every geometry connected to the root node.
264
265        Returns
266        -----------
267        is_valid : bool
268          Does every geometry have a transform
269        """
270        if len(self.geometry) == 0:
271            return True
272
273        try:
274            referenced = {self.graph[i][1]
275                          for i in self.graph.nodes_geometry}
276        except BaseException:
277            # if connectivity to world frame is broken return false
278            return False
279
280        # every geometry is referenced
281        ok = referenced == set(self.geometry.keys())
282
283        return ok
284
285    @caching.cache_decorator
286    def bounds_corners(self):
287        """
288        A list of points that represent the corners of the
289        AABB of every geometry in the scene.
290
291        This can be useful if you want to take the AABB in
292        a specific frame.
293
294        Returns
295        -----------
296        corners: (n, 3) float, points in space
297        """
298        # the saved corners of each instance
299        corners_inst = []
300        # (n, 3) float corners of each geometry
301        corners_geom = {k: bounds_module.corners(v.bounds)
302                        for k, v in self.geometry.items()
303                        if v.bounds is not None}
304        if len(corners_geom) == 0:
305            return np.array([])
306
307        for node_name in self.graph.nodes_geometry:
308            # access the transform and geometry name from node
309            transform, geometry_name = self.graph[node_name]
310            # not all nodes have associated geometry
311            if geometry_name not in corners_geom:
312                continue
313            # transform geometry corners into where
314            # the instance of the geometry is located
315            corners_inst.extend(
316                transformations.transform_points(
317                    corners_geom[geometry_name],
318                    transform))
319        # make corners numpy array
320        corners_inst = np.array(corners_inst,
321                                dtype=np.float64)
322        return corners_inst
323
324    @caching.cache_decorator
325    def bounds(self):
326        """
327        Return the overall bounding box of the scene.
328
329        Returns
330        --------
331        bounds : (2, 3) float or None
332          Position of [min, max] bounding box
333          Returns None if no valid bounds exist
334        """
335        corners = self.bounds_corners
336        if len(corners) == 0:
337            return None
338        bounds = np.array([corners.min(axis=0),
339                           corners.max(axis=0)])
340        return bounds
341
342    @caching.cache_decorator
343    def extents(self):
344        """
345        Return the axis aligned box size of the current scene.
346
347        Returns
348        ----------
349        extents : (3,) float
350          Bounding box sides length
351        """
352        return np.diff(self.bounds, axis=0).reshape(-1)
353
354    @caching.cache_decorator
355    def scale(self):
356        """
357        The approximate scale of the mesh
358
359        Returns
360        -----------
361        scale : float
362          The mean of the bounding box edge lengths
363        """
364        scale = (self.extents ** 2).sum() ** .5
365        return scale
366
367    @caching.cache_decorator
368    def centroid(self):
369        """
370        Return the center of the bounding box for the scene.
371
372        Returns
373        --------
374        centroid : (3) float
375          Point for center of bounding box
376        """
377        centroid = np.mean(self.bounds, axis=0)
378        return centroid
379
380    @caching.cache_decorator
381    def triangles(self):
382        """
383        Return a correctly transformed polygon soup of the
384        current scene.
385
386        Returns
387        ----------
388        triangles : (n, 3, 3) float
389          Triangles in space
390        """
391        triangles = collections.deque()
392        triangles_node = collections.deque()
393
394        for node_name in self.graph.nodes_geometry:
395            # which geometry does this node refer to
396            transform, geometry_name = self.graph[node_name]
397
398            # get the actual potential mesh instance
399            geometry = self.geometry[geometry_name]
400            if not hasattr(geometry, 'triangles'):
401                continue
402            # append the (n, 3, 3) triangles to a sequence
403            triangles.append(
404                transformations.transform_points(
405                    geometry.triangles.copy().reshape((-1, 3)),
406                    matrix=transform))
407            # save the node names for each triangle
408            triangles_node.append(
409                np.tile(node_name,
410                        len(geometry.triangles)))
411        # save the resulting nodes to the cache
412        self._cache['triangles_node'] = np.hstack(triangles_node)
413        triangles = np.vstack(triangles).reshape((-1, 3, 3))
414        return triangles
415
416    @caching.cache_decorator
417    def triangles_node(self):
418        """
419        Which node of self.graph does each triangle come from.
420
421        Returns
422        ---------
423        triangles_index : (len(self.triangles),)
424          Node name for each triangle
425        """
426        populate = self.triangles  # NOQA
427        return self._cache['triangles_node']
428
429    @caching.cache_decorator
430    def geometry_identifiers(self):
431        """
432        Look up geometries by identifier MD5
433
434        Returns
435        ---------
436        identifiers : dict
437          {Identifier MD5: key in self.geometry}
438        """
439        identifiers = {mesh.identifier_md5: name
440                       for name, mesh in self.geometry.items()}
441        return identifiers
442
443    @caching.cache_decorator
444    def duplicate_nodes(self):
445        """
446        Return a sequence of node keys of identical meshes.
447
448        Will include meshes with different geometry but identical
449        spatial hashes as well as meshes repeated by self.nodes.
450
451        Returns
452        -----------
453        duplicates : (m) sequenc
454          Keys of self.nodes that represent identical geometry
455        """
456        # if there is no geometry we can have no duplicate nodes
457        if len(self.geometry) == 0:
458            return []
459
460        # geometry name : md5 of mesh
461        mesh_hash = {k: int(m.identifier_md5, 16)
462                     for k, m in self.geometry.items()}
463        # the name of nodes in the scene graph with geometry
464        node_names = np.array(self.graph.nodes_geometry)
465        # the geometry names for each node in the same order
466        node_geom = np.array([self.graph[i][1] for i in node_names])
467        # the mesh md5 for each node in the same order
468        node_hash = np.array([mesh_hash[v] for v in node_geom])
469        # indexes of identical hashes
470        node_groups = grouping.group(node_hash)
471        # sequence of node names where each
472        # sublist has identical geometry
473        duplicates = [np.sort(node_names[g]).tolist()
474                      for g in node_groups]
475        return duplicates
476
477    def deduplicated(self):
478        """
479        Return a new scene where each unique geometry is only
480        included once and transforms are discarded.
481
482        Returns
483        -------------
484        dedupe : Scene
485          One copy of each unique geometry from scene
486        """
487        # collect geometry
488        geometry = {}
489        # loop through groups of identical nodes
490        for group in self.duplicate_nodes:
491            # get the name of the geometry
492            name = self.graph[group[0]][1]
493            # collect our unique collection of geometry
494            geometry[name] = self.geometry[name]
495
496        return Scene(geometry)
497
498    def set_camera(self,
499                   angles=None,
500                   distance=None,
501                   center=None,
502                   resolution=None,
503                   fov=None):
504        """
505        Create a camera object for self.camera, and add
506        a transform to self.graph for it.
507
508        If arguments are not passed sane defaults will be figured
509        out which show the mesh roughly centered.
510
511        Parameters
512        -----------
513        angles : (3,) float
514          Initial euler angles in radians
515        distance : float
516          Distance from centroid
517        center : (3,) float
518          Point camera should be center on
519        camera : Camera object
520          Object that stores camera parameters
521        """
522
523        if fov is None:
524            fov = np.array([60, 45])
525
526        # if no geometry nothing to set camera to
527        if len(self.geometry) == 0:
528            self._camera = cameras.Camera(fov=fov)
529            self.graph[self._camera.name] = None
530            return self._camera
531        # set with no rotation by default
532        if angles is None:
533            angles = np.zeros(3)
534
535        rotation = transformations.euler_matrix(*angles)
536        transform = cameras.look_at(
537            self.bounds_corners,
538            fov=fov,
539            rotation=rotation,
540            distance=distance,
541            center=center)
542
543        if hasattr(self, '_camera') and self._camera is not None:
544            self._camera.fov = fov
545            if resolution is not None:
546                self._camera.resolution = resolution
547        else:
548            # create a new camera object
549            self._camera = cameras.Camera(fov=fov, resolution=resolution)
550
551        self.graph[self._camera.name] = transform
552
553        return self._camera
554
555    @property
556    def camera_transform(self):
557        """
558        Get camera transform in the base frame
559
560        Returns
561        -------
562        camera_transform : (4, 4) float
563          Camera transform in the base frame
564        """
565        return self.graph[self.camera.name][0]
566
567    def camera_rays(self):
568        """
569        Calculate the trimesh.scene.Camera origin and ray
570        direction vectors. Returns one ray per pixel as set
571        in camera.resolution
572
573        Returns
574        --------------
575        origins: (n, 3) float
576          Ray origins in space
577        vectors: (n, 3) float
578          Ray direction unit vectors in world coordinates
579        pixels : (n, 2) int
580          Which pixel does each ray correspond to in an image
581        """
582        vectors, pixels = self.camera.to_rays()
583        transform = self.camera_transform
584
585        # apply the rotation to the direction vectors
586        vectors = transformations.transform_points(
587            vectors,
588            transform,
589            translate=False)
590        # camera origin is single point so extract from transform
591        origins = (np.ones_like(vectors) *
592                   transformations.translation_from_matrix(
593                       transform))
594        return origins, vectors, pixels
595
596    @camera_transform.setter
597    def camera_transform(self, camera_transform):
598        """
599        Set the camera transform in the base frame
600
601        Parameters
602        ----------
603        camera_transform : (4, 4) float
604          Camera transform in the base frame
605        """
606        if camera_transform is None:
607            return
608        self.graph[self.camera.name] = camera_transform
609
610    @property
611    def camera(self):
612        """
613        Get the single camera for the scene. If not manually
614        set one will abe automatically generated.
615
616        Returns
617        ----------
618        camera : trimesh.scene.Camera
619          Camera object defined for the scene
620        """
621        # no camera set for the scene yet
622        if not hasattr(self, '_camera') or self._camera is None:
623            # will create a camera with everything in view
624            return self.set_camera()
625        assert self._camera is not None
626
627        return self._camera
628
629    @camera.setter
630    def camera(self, camera):
631        """
632        Set a camera object for the Scene.
633
634        Parameters
635        -----------
636        camera : trimesh.scene.Camera
637          Camera object for the scene
638        """
639        if camera is None:
640            return
641        self._camera = camera
642
643    @property
644    def lights(self):
645        """
646        Get a list of the lights in the scene. If nothing is
647        set it will generate some automatically.
648
649        Returns
650        -------------
651        lights : [trimesh.scene.lighting.Light]
652          Lights in the scene.
653        """
654        if not hasattr(self, '_lights') or self._lights is None:
655            # do some automatic lighting
656            lights, transforms = lighting.autolight(self)
657            # assign the transforms to the scene graph
658            for L, T in zip(lights, transforms):
659                self.graph[L.name] = T
660            # set the lights
661            self._lights = lights
662        return self._lights
663
664    @lights.setter
665    def lights(self, lights):
666        """
667        Assign a list of light objects to the scene
668
669        Parameters
670        --------------
671        lights : [trimesh.scene.lighting.Light]
672          Lights in the scene.
673        """
674        self._lights = lights
675
676    def rezero(self):
677        """
678        Move the current scene so that the AABB of the whole
679        scene is centered at the origin.
680
681        Does this by changing the base frame to a new, offset
682        base frame.
683        """
684        if self.is_empty or np.allclose(self.centroid, 0.0):
685            # early exit since what we want already exists
686            return
687
688        # the transformation to move the overall scene to AABB centroid
689        matrix = np.eye(4)
690        matrix[:3, 3] = -self.centroid
691
692        # we are going to change the base frame
693        new_base = str(self.graph.base_frame) + '_I'
694        self.graph.update(frame_from=new_base,
695                          frame_to=self.graph.base_frame,
696                          matrix=matrix)
697        self.graph.base_frame = new_base
698
699    def dump(self, concatenate=False):
700        """
701        Append all meshes in scene to a list of meshes.
702
703        Returns
704        ----------
705        dumped : (n,) list
706          Trimesh objects transformed to their
707          location the scene.graph
708        """
709        result = []
710        for node_name in self.graph.nodes_geometry:
711            transform, geometry_name = self.graph[node_name]
712            # get a copy of the geometry
713            current = self.geometry[geometry_name].copy()
714            # move the geometry vertices into the requested frame
715            current.apply_transform(transform)
716            # save to our list of meshes
717            result.append(current)
718
719        if concatenate:
720            return util.concatenate(result)
721
722        return np.array(result)
723
724    @caching.cache_decorator
725    def convex_hull(self):
726        """
727        The convex hull of the whole scene
728
729        Returns
730        ---------
731        hull: Trimesh object, convex hull of all meshes in scene
732        """
733        points = util.vstack_empty([m.vertices for m in self.dump()])
734        hull = convex.convex_hull(points)
735        return hull
736
737    def export(self, file_obj=None, file_type=None, **kwargs):
738        """
739        Export a snapshot of the current scene.
740
741        Parameters
742        ----------
743        file_type: what encoding to use for meshes
744                   ie: dict, dict64, stl
745
746        Returns
747        ----------
748        export : bytes
749          Only returned if file_obj is None
750        """
751
752        # if we weren't passed a file type extract from file_obj
753        if file_type is None:
754            file_type = str(file_obj).split('.')[-1]
755
756        # always remove whitepace and leading characters
757        file_type = file_type.strip().lower().lstrip('.')
758
759        if file_type == 'gltf':
760            data = gltf.export_gltf(self, **kwargs)
761        elif file_type == 'glb':
762            data = gltf.export_glb(self, **kwargs)
763        elif file_type == 'dict':
764            from ..exchange.export import scene_to_dict
765            data = scene_to_dict(self)
766        elif file_type == 'dict64':
767            from ..exchange.export import scene_to_dict
768            data = scene_to_dict(self, use_base64=True)
769        else:
770            raise ValueError('unsupported export format: {}'.format(file_type))
771
772        # now write the data or return bytes of result
773        if hasattr(file_obj, 'write'):
774            # if it's just a regular file object
775            file_obj.write(data)
776        elif util.is_string(file_obj):
777            # assume strings are file paths
778            file_path = os.path.expanduser(os.path.abspath(file_obj))
779            with open(file_path, 'wb') as f:
780                f.write(data)
781        else:
782            # no writeable file object so return data
783            return data
784
785    def save_image(self, resolution=None, **kwargs):
786        """
787        Get a PNG image of a scene.
788
789        Parameters
790        -----------
791        resolution : (2,) int
792          Resolution to render image
793        **kwargs
794          Passed to SceneViewer constructor
795
796        Returns
797        -----------
798        png : bytes
799          Render of scene as a PNG
800        """
801        from ..viewer import render_scene
802        png = render_scene(scene=self,
803                           resolution=resolution,
804                           **kwargs)
805        return png
806
807    @property
808    def units(self):
809        """
810        Get the units for every model in the scene, and
811        raise a ValueError if there are mixed units.
812
813        Returns
814        -----------
815        units : str
816          Units for every model in the scene
817        """
818        existing = [i.units for i in self.geometry.values()]
819
820        if any(existing[0] != e for e in existing):
821            # if all of our geometry doesn't have the same units already
822            # this function will only do some hot nonsense
823            raise ValueError('models in scene have inconsistent units!')
824
825        return existing[0]
826
827    @units.setter
828    def units(self, value):
829        """
830        Set the units for every model in the scene without
831        converting any units just setting the tag.
832
833        Parameters
834        ------------
835        value : str
836          Value to set every geometry unit value to
837        """
838        for m in self.geometry.values():
839            m.units = value
840
841    def convert_units(self, desired, guess=False):
842        """
843        If geometry has units defined convert them to new units.
844
845        Returns a new scene with geometries and transforms scaled.
846
847        Parameters
848        ----------
849        desired : str
850          Desired final unit system: 'inches', 'mm', etc.
851        guess : bool
852          Is the converter allowed to guess scale when models
853          don't have it specified in their metadata.
854
855        Returns
856        ----------
857        scaled : trimesh.Scene
858          Copy of scene with scaling applied and units set
859          for every model
860        """
861        # if there is no geometry do nothing
862        if len(self.geometry) == 0:
863            return self.copy()
864
865        current = self.units
866        if current is None:
867            # will raise ValueError if not in metadata
868            # and not allowed to guess
869            current = units.units_from_metadata(self, guess=guess)
870
871        # find the float conversion
872        scale = units.unit_conversion(current=current,
873                                      desired=desired)
874
875        # exit early if our current units are the same as desired units
876        if np.isclose(scale, 1.0):
877            result = self.copy()
878        else:
879            result = self.scaled(scale=scale)
880
881        # apply the units to every geometry of the scaled result
882        result.units = desired
883
884        return result
885
886    def explode(self, vector=None, origin=None):
887        """
888        Explode a scene around a point and vector.
889
890        Parameters
891        -----------
892        vector : (3,) float or float
893           Explode radially around a direction vector or spherically
894        origin : (3,) float
895          Point to explode around
896        """
897        if origin is None:
898            origin = self.centroid
899        if vector is None:
900            vector = self.scale / 25.0
901
902        vector = np.asanyarray(vector, dtype=np.float64)
903        origin = np.asanyarray(origin, dtype=np.float64)
904
905        for node_name in self.graph.nodes_geometry:
906            transform, geometry_name = self.graph[node_name]
907
908            centroid = self.geometry[geometry_name].centroid
909            # transform centroid into nodes location
910            centroid = np.dot(transform,
911                              np.append(centroid, 1))[:3]
912
913            if vector.shape == ():
914                # case where our vector is a single number
915                offset = (centroid - origin) * vector
916            elif np.shape(vector) == (3,):
917                projected = np.dot(vector, (centroid - origin))
918                offset = vector * projected
919            else:
920                raise ValueError('explode vector wrong shape!')
921
922            transform[0:3, 3] += offset
923            self.graph[node_name] = transform
924
925    def scaled(self, scale):
926        """
927        Return a copy of the current scene, with meshes and scene
928        transforms scaled to the requested factor.
929
930        Parameters
931        -----------
932        scale : float
933          Factor to scale meshes and transforms
934
935        Returns
936        -----------
937        scaled : trimesh.Scene
938          A copy of the current scene but scaled
939        """
940        scale = float(scale)
941        # matrix for 2D scaling
942        scale_2D = np.eye(3) * scale
943        # matrix for 3D scaling
944        scale_3D = np.eye(4) * scale
945
946        # preallocate transforms and geometries
947        nodes = self.graph.nodes_geometry
948        transforms = np.zeros((len(nodes), 4, 4))
949        geometries = [None] * len(nodes)
950
951        # collect list of transforms
952        for i, node in enumerate(nodes):
953            transforms[i], geometries[i] = self.graph[node]
954
955        # result is a copy
956        result = self.copy()
957        # remove all existing transforms
958        result.graph.clear()
959
960        for group in grouping.group(geometries):
961            # hashable reference to self.geometry
962            geometry = geometries[group[0]]
963            # original transform from world to geometry
964            original = transforms[group[0]]
965            # transform for geometry
966            new_geom = np.dot(scale_3D, original)
967
968            if result.geometry[geometry].vertices.shape[1] == 2:
969                # if our scene is 2D only scale in 2D
970                result.geometry[geometry].apply_transform(scale_2D)
971            else:
972                # otherwise apply the full transform
973                result.geometry[geometry].apply_transform(new_geom)
974
975            for node, T in zip(self.graph.nodes_geometry[group],
976                               transforms[group]):
977                # generate the new transforms
978                transform = util.multi_dot(
979                    [scale_3D, T, np.linalg.inv(new_geom)])
980                # apply scale to translation
981                transform[:3, 3] *= scale
982                # update scene with new transforms
983                result.graph.update(frame_to=node,
984                                    matrix=transform,
985                                    geometry=geometry)
986        return result
987
988    def copy(self):
989        """
990        Return a deep copy of the current scene
991
992        Returns
993        ----------
994        copied : trimesh.Scene
995          Copy of the current scene
996        """
997        # use the geometries copy method to
998        # allow them to handle references to unpickle-able objects
999        geometry = {n: g.copy() for n, g in self.geometry.items()}
1000
1001        if not hasattr(self, '_camera') or self._camera is None:
1002            # if no camera set don't include it
1003            camera = None
1004        else:
1005            # otherwise get a copy of the camera
1006            camera = self.camera.copy()
1007        # create a new scene with copied geometry and graph
1008        copied = Scene(geometry=geometry,
1009                       graph=self.graph.copy(),
1010                       camera=camera)
1011        return copied
1012
1013    def show(self, viewer=None, **kwargs):
1014        """
1015        Display the current scene.
1016
1017        Parameters
1018        -----------
1019        viewer: str
1020          What kind of viewer to open, including
1021          'gl' to open a pyglet window, 'notebook'
1022          for a jupyter notebook or None
1023        kwargs : dict
1024          Includes `smooth`, which will turn
1025          on or off automatic smooth shading
1026        """
1027
1028        if viewer is None:
1029            # check to see if we are in a notebook or not
1030            from ..viewer import in_notebook
1031            viewer = 'gl'
1032            if in_notebook():
1033                viewer = 'notebook'
1034
1035        if viewer == 'gl':
1036            # this imports pyglet, and will raise an ImportError
1037            # if pyglet is not available
1038            from ..viewer import SceneViewer
1039            return SceneViewer(self, **kwargs)
1040        elif viewer == 'notebook':
1041            from ..viewer import scene_to_notebook
1042            return scene_to_notebook(self, **kwargs)
1043        else:
1044            raise ValueError('viewer must be "gl", "notebook", or None')
1045
1046    def __add__(self, other):
1047        """
1048        Concatenate the current scene with another scene or mesh.
1049
1050        Parameters
1051        ------------
1052        other : trimesh.Scene, trimesh.Trimesh, trimesh.Path
1053           Other object to append into the result scene
1054
1055        Returns
1056        ------------
1057        appended : trimesh.Scene
1058           Scene with geometry from both scenes
1059        """
1060        result = append_scenes([self, other],
1061                               common=[self.graph.base_frame])
1062        return result
1063
1064
1065def split_scene(geometry):
1066    """
1067    Given a geometry, list of geometries, or a Scene
1068    return them as a single Scene object.
1069
1070    Parameters
1071    ----------
1072    geometry : splittable
1073
1074    Returns
1075    ---------
1076    scene: trimesh.Scene
1077    """
1078    # already a scene, so return it
1079    if util.is_instance_named(geometry, 'Scene'):
1080        return geometry
1081
1082    # a list of things
1083    if util.is_sequence(geometry):
1084        metadata = {}
1085        for g in geometry:
1086            try:
1087                metadata.update(g.metadata)
1088            except BaseException:
1089                continue
1090        return Scene(geometry,
1091                     metadata=metadata)
1092
1093    # a single geometry so we are going to split
1094    split = []
1095    metadata = {}
1096    for g in util.make_sequence(geometry):
1097        split.extend(g.split())
1098        metadata.update(g.metadata)
1099
1100    # if there is only one geometry in the mesh
1101    # name it from the file name
1102    if len(split) == 1 and 'file_name' in metadata:
1103        split = {metadata['file_name']: split[0]}
1104
1105    scene = Scene(split, metadata=metadata)
1106
1107    return scene
1108
1109
1110def append_scenes(iterable, common=['world']):
1111    """
1112    Concatenate multiple scene objects into one scene.
1113
1114    Parameters
1115    -------------
1116    iterable : (n,) Trimesh or Scene
1117       Geometries that should be appended
1118    common : (n,) str
1119       Nodes that shouldn't be remapped
1120
1121    Returns
1122    ------------
1123    result : trimesh.Scene
1124       Scene containing all geometry
1125    """
1126    if isinstance(iterable, Scene):
1127        return iterable
1128
1129    # save geometry in dict
1130    geometry = {}
1131    # save transforms as edge tuples
1132    edges = []
1133
1134    # nodes which shouldn't be remapped
1135    common = set(common)
1136    # nodes which are consumed and need to be remapped
1137    consumed = set()
1138
1139    def node_remap(node):
1140        """
1141        Remap node to new name if necessary
1142
1143        Parameters
1144        -------------
1145        node : hashable
1146           Node name in original scene
1147
1148        Returns
1149        -------------
1150        name : hashable
1151           Node name in concatenated scene
1152        """
1153
1154        # if we've already remapped a node use it
1155        if node in map_node:
1156            return map_node[node]
1157
1158        # if a node is consumed and isn't one of the nodes
1159        # we're going to hold common between scenes remap it
1160        if node not in common and node in consumed:
1161            name = str(node) + '-' + util.unique_id().upper()
1162            map_node[node] = name
1163            node = name
1164
1165        # keep track of which nodes have been used
1166        # in the current scene
1167        current.add(node)
1168        return node
1169
1170    # loop through every geometry
1171    for s in iterable:
1172        # allow Trimesh/Path2D geometry to be passed
1173        if hasattr(s, 'scene'):
1174            s = s.scene()
1175        # if we don't have a scene raise an exception
1176        if not isinstance(s, Scene):
1177            raise ValueError('{} is not a scene!'.format(
1178                type(s).__name__))
1179
1180        # remap geometries if they have been consumed
1181        map_geom = {}
1182        for k, v in s.geometry.items():
1183            # if a geometry already exists add a UUID to the name
1184            if k in geometry:
1185                name = str(k) + '-' + util.unique_id().upper()
1186            else:
1187                name = k
1188            # store name mapping
1189            map_geom[k] = name
1190            # store geometry with new name
1191            geometry[name] = v
1192
1193        # remap nodes and edges so duplicates won't
1194        # stomp all over each other
1195        map_node = {}
1196        # the nodes used in this scene
1197        current = set()
1198        for a, b, attr in s.graph.to_edgelist():
1199            # remap node names from local names
1200            a, b = node_remap(a), node_remap(b)
1201            # remap geometry keys
1202            # if key is not in map_geom it means one of the scenes
1203            # referred to geometry that doesn't exist
1204            # rather than crash here we ignore it as the user
1205            # possibly intended to add in geometries back later
1206            if 'geometry' in attr and attr['geometry'] in map_geom:
1207                attr['geometry'] = map_geom[attr['geometry']]
1208            # save the new edge
1209            edges.append((a, b, attr))
1210        # mark nodes from current scene as consumed
1211        consumed.update(current)
1212
1213    # add all data to a new scene
1214    result = Scene()
1215    result.graph.from_edgelist(edges)
1216    result.geometry.update(geometry)
1217
1218    return result
1219