import numpy as np
import collections
import uuid

from . import cameras
from . import lighting

from .. import util
from .. import units
from .. import convex
from .. import caching
from .. import grouping
from .. import transformations

from ..util import unique_name
from ..exchange import export
from ..parent import Geometry3D

from .transforms import SceneGraph


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.
    """

    def __init__(self,
                 geometry=None,
                 base_frame='world',
                 metadata={},
                 graph=None,
                 camera=None,
                 lights=None,
                 camera_transform=None):
        """
        Create a new Scene object.

        Parameters
        -------------
        geometry : Trimesh, Path2D, Path3D PointCloud or list
          Geometry to initially add to the scene
        base_frame : str or hashable
          Name of base frame
        metadata : dict
          Any metadata about the scene
        graph : TransformForest or None
          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 : (4, 4) float or None
          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__)

        # add passed geometry to scene
        self.add_geometry(geometry)

        # hold metadata about the scene
        self.metadata = {}
        self.metadata.update(metadata)

        if graph is not None:
            # if we've been passed a graph override the default
            self.graph = graph

        self.camera = camera
        self.lights = lights

        if camera is not None and camera_transform is not None:
            self.camera_transform = camera_transform

    def apply_transform(self, transform):
        """
        Apply a transform to all children of the base frame
        without modifying any geometry.

        Parameters
        --------------
        transform : (4, 4)
          Homogeneous transformation matrix.
        """
        base = self.graph.base_frame
        for child in self.graph.transforms.children[base]:
            combined = np.dot(transform, self.graph[child][0])
            self.graph.update(frame_from=base,
                              frame_to=child,
                              matrix=combined)
        return self

    def add_geometry(self,
                     geometry,
                     node_name=None,
                     geom_name=None,
                     parent_node_name=None,
                     transform=None,
                     extras=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: Name of the added node.
        geom_name: Name of the added geometry.
        parent_node_name: Name of the parent node in the graph.
        transform: Transform that applies to the added node.
        extras: 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,
                extras=extras) for value in geometry]
        elif isinstance(geometry, dict):
            # if someone passed us a dict of geometry
            return {k: self.add_geometry(v, geom_name=k, extras=extras)
                    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.warning('unknown type ({}) added to scene!'.format(
                type(geometry).__name__))
            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},
                          extras=extras)

        return node_name

    def delete_geometry(self, names):
        """
        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]

    def strip_visuals(self):
        """
        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)

    def __hash__(self):
        """
        Return information about scene which is hashable.

        Returns
        ---------
        hashable : str
          Data which can be hashed.
        """
        # avoid accessing attribute in tight loop
        geometry = self.geometry
        # 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):
        """
        Does the scene have anything in it.

        Returns
        ----------
        is_empty: bool, True if nothing is in the scene
        """

        is_empty = len(self.geometry) == 0
        return is_empty

    @property
    def is_valid(self):
        """
        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
        ok = referenced == set(self.geometry.keys())

        return ok

    @caching.cache_decorator
    def bounds_corners(self):
        """
        Get the post-transform AABB for each node
        which has geometry defined.

        Returns
        -----------
        corners : dict
          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):
        """
        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):
        """
        Return the axis aligned box size of the current scene.

        Returns
        ----------
        extents : (3,) float
          Bounding box sides length
        """
        return np.diff(self.bounds, axis=0).reshape(-1)

    @caching.cache_decorator
    def scale(self):
        """
        The approximate scale of the mesh

        Returns
        -----------
        scale : float
          The mean of the bounding box edge lengths
        """
        scale = (self.extents ** 2).sum() ** .5
        return scale

    @caching.cache_decorator
    def centroid(self):
        """
        Return the center of the bounding box for the scene.

        Returns
        --------
        centroid : (3) float
          Point for center of bounding box
        """
        centroid = np.mean(self.bounds, axis=0)
        return centroid

    @caching.cache_decorator
    def area(self):
        """
        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 triangles(self):
        """
        Return a correctly transformed polygon soup of the
        current scene.

        Returns
        ----------
        triangles : (n, 3, 3) float
          Triangles in space
        """
        triangles = collections.deque()
        triangles_node = collections.deque()

        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)
        triangles = np.vstack(triangles).reshape((-1, 3, 3))
        return triangles

    @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):
        """
        Look up geometries by identifier hash

        Returns
        ---------
        identifiers : dict
          {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):
        """
        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 : (m) sequence
          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())

    def deduplicated(self):
        """
        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)

    def set_camera(self,
                   angles=None,
                   distance=None,
                   center=None,
                   resolution=None,
                   fov=None):
        """
        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):
        """
        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

    def camera_rays(self):
        """
        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):
        """
        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):
        """
        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):
        return hasattr(self, '_camera') and self._camera is not None

    @property
    def lights(self):
        """
        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):
        """
        Assign a list of light objects to the scene

        Parameters
        --------------
        lights : [trimesh.scene.lighting.Light]
          Lights in the scene.
        """
        self._lights = lights

    def rezero(self):
        """
        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

    def dump(self, concatenate=False):
        """
        Append all meshes in scene freezing transforms.

        Parameters
        ------------
        concatenate : bool
          If True, concatenate results into single mesh

        Returns
        ----------
        dumped : (n,) Trimesh or Trimesh
          Trimesh objects transformed to their
          location the scene.graph
        """
        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()
            # move the geometry vertices into the requested frame
            current.apply_transform(transform)
            current.metadata['name'] = node_name
            # save to our list of meshes
            result.append(current)

        if concatenate:
            return util.concatenate(result)

        return np.array(result)

    def subscene(self, node):
        """
        Get part of a scene that succeeds a specified node.

        Parameters
        ------------
        node : any
          Hashable key in `scene.graph`

        Returns
        -----------
        subscene : Scene
          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 = set([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 object, convex hull of all meshes in scene
        """
        points = util.vstack_empty(
            [m.vertices
             for m in self.dump()])
        hull = convex.convex_hull(points)
        return hull

    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)

    def save_image(self, resolution=None, **kwargs):
        """
        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
        png = render_scene(
            scene=self, resolution=resolution, **kwargs)
        return png

    @property
    def units(self):
        """
        Get the units for every model in the scene, and
        raise a ValueError if there are mixed units.

        Returns
        -----------
        units : str
          Units for every model in the scene
        """
        existing = [i.units for i in self.geometry.values()]

        if any(existing[0] != e for e in existing):
            # if all of our geometry doesn't have the same units already
            # this function will only do some hot nonsense
            raise ValueError('models in scene have inconsistent units!')

        return existing[0]

    @units.setter
    def units(self, value):
        """
        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
        """
        for m in self.geometry.values():
            m.units = value

    def convert_units(self, desired, guess=False):
        """
        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

    def explode(self, vector=None, origin=None):
        """
        Explode a scene 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!')

            transform[:3, 3] += offset
            self.graph[node_name] = transform

    def scaled(self, scale):
        """
        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),
                            extras=result.graph.transforms.edge_data[(
                                p, n)].get('extras', 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)
                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']
                    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

    def copy(self):
        """
        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

    def show(self, viewer=None, **kwargs):
        """
        Display the current scene.

        Parameters
        -----------
        viewer: str
          What kind of viewer to open, including
          '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
            viewer = 'gl'
            if in_notebook():
                viewer = 'notebook'

        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)
        else:
            raise ValueError('viewer must be "gl", "notebook", or None')

    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=['world'], 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

    # 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('{} is not a scene!'.format(
                type(s).__name__))

        # 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
