Repository: mikedh/trimesh

Issue:
Class constructors documentation
Hi there. You have a good documentation in code for `__init__` methods of classes, but this documentation isn't shown on Sphinx pages.

Is it a bug or a feature?

I could try to configure Sphinx to show such documentation.

Discussion:

Patch:
diff --git a/docs/conf.py b/docs/conf.py
index a3fd76d2..42b6d771 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -109,8 +109,8 @@ html_context = {
 # Output file base name for HTML help builder.
 htmlhelp_basename = 'trimeshdoc'
 
-# -- Extensions configuration ----------------------------------------
-
+# -- Extensions configuration ----------------------------------
 autodoc_default_options = {
     'autosummary': True,
+    'special-members': '__init__',
 }
diff --git a/models/BoxTextured.ply b/models/BoxTextured.ply
new file mode 100644
index 00000000..4aa2443e
--- /dev/null
+++ b/models/BoxTextured.ply
@@ -0,0 +1,51 @@
+ply
+format ascii 1.0
+comment Created by Blender 2.83.1 - www.blender.org, source file: ''
+element vertex 24
+property float x
+property float y
+property float z
+property float nx
+property float ny
+property float nz
+property float s
+property float t
+element face 12
+property list uchar uint vertex_indices
+end_header
+-0.500000 -0.500000 0.500000 0.000000 0.000000 1.000000 6.000000 1.000000
+0.500000 -0.500000 0.500000 0.000000 0.000000 1.000000 5.000000 1.000000
+-0.500000 0.500000 0.500000 0.000000 0.000000 1.000000 6.000000 0.000000
+0.500000 0.500000 0.500000 -0.000000 0.000000 1.000000 5.000000 0.000000
+0.500000 0.500000 0.500000 1.000000 -0.000000 -0.000000 4.000000 1.000000
+0.500000 -0.500000 0.500000 1.000000 -0.000000 -0.000000 5.000000 1.000000
+0.500000 0.500000 -0.500000 1.000000 -0.000000 -0.000000 4.000000 0.000000
+0.500000 -0.500000 -0.500000 1.000000 0.000000 0.000000 5.000000 0.000000
+-0.500000 0.500000 0.500000 0.000000 1.000000 -0.000000 2.000000 1.000000
+0.500000 0.500000 0.500000 0.000000 1.000000 -0.000000 1.000000 1.000000
+-0.500000 0.500000 -0.500000 0.000000 1.000000 -0.000000 2.000000 0.000000
+0.500000 0.500000 -0.500000 0.000000 1.000000 -0.000000 1.000000 0.000000
+0.500000 -0.500000 0.500000 0.000000 -1.000000 0.000000 3.000000 1.000000
+-0.500000 -0.500000 0.500000 0.000000 -1.000000 0.000000 4.000000 1.000000
+0.500000 -0.500000 -0.500000 0.000000 -1.000000 0.000000 3.000000 0.000000
+-0.500000 -0.500000 -0.500000 0.000000 -1.000000 0.000000 4.000000 0.000000
+-0.500000 -0.500000 0.500000 -1.000000 0.000000 0.000000 3.000000 1.000000
+-0.500000 0.500000 0.500000 -1.000000 0.000000 0.000000 2.000000 1.000000
+-0.500000 -0.500000 -0.500000 -1.000000 0.000000 0.000000 3.000000 0.000000
+-0.500000 0.500000 -0.500000 -1.000000 0.000000 -0.000000 2.000000 0.000000
+-0.500000 -0.500000 -0.500000 0.000000 -0.000000 -1.000000 0.000000 1.000000
+-0.500000 0.500000 -0.500000 0.000000 -0.000000 -1.000000 0.000000 0.000000
+0.500000 -0.500000 -0.500000 0.000000 -0.000000 -1.000000 1.000000 1.000000
+0.500000 0.500000 -0.500000 0.000000 -0.000000 -1.000000 1.000000 0.000000
+3 0 1 2
+3 3 2 1
+3 4 5 6
+3 7 6 5
+3 8 9 10
+3 11 10 9
+3 12 13 14
+3 15 14 13
+3 16 17 18
+3 19 18 17
+3 20 21 22
+3 23 22 21
diff --git a/models/points_emptyascii.ply b/models/points_emptyascii.ply
new file mode 100644
index 00000000..badbba1f
--- /dev/null
+++ b/models/points_emptyascii.ply
@@ -0,0 +1,23 @@
+ply
+format ascii 1.0
+comment VCGLIB generated
+element vertex 12
+property float x
+property float y
+property float z
+element face 0
+property list uchar int vertex_indices
+end_header
+-0.010441 -0.013788 0.0382 
+-0.010369 -0.013788 0.0382 
+-0.010296 -0.013788 0.0382 
+-0.010223 -0.013788 0.0382 
+-0.01015 -0.013788 0.0382 
+-0.010078 -0.013788 0.0382 
+-0.010005 -0.013788 0.0382 
+-0.009932 -0.013788 0.0382 
+-0.009859 -0.013788 0.0382 
+-0.009786 -0.013788 0.0382 
+-0.009765 -0.013861 0.0384 
+-0.009691 -0.013861 0.0384 
+-0.009618 -0.013861 0.0384
\ No newline at end of file
diff --git a/trimesh/exchange/__init__.py b/trimesh/exchange/__init__.py
index 728d5a28..c60e709d 100644
--- a/trimesh/exchange/__init__.py
+++ b/trimesh/exchange/__init__.py
@@ -4,7 +4,7 @@ trimesh/exchange
 
 Contains the importers and exporters for various mesh formats.
 
-Note that you should probably not be using these directly, if
+Note that *you should probably not be using these directly*, if
 you call `trimesh.load` it will then call and wrap the result
 of the various loaders:
 
diff --git a/trimesh/exchange/load.py b/trimesh/exchange/load.py
index f9d3bb1a..07aacb2f 100644
--- a/trimesh/exchange/load.py
+++ b/trimesh/exchange/load.py
@@ -10,6 +10,8 @@ from ..scene.scene import Scene, append_scenes
 from ..constants import log_time, log
 
 from . import misc
+
+from .xyz import _xyz_loaders
 from .ply import _ply_loaders
 from .stl import _stl_loaders
 from .dae import _collada_loaders
@@ -17,12 +19,12 @@ from .obj import _obj_loaders
 from .off import _off_loaders
 from .misc import _misc_loaders
 from .gltf import _gltf_loaders
+from .xaml import _xaml_loaders
+from .binvox import _binvox_loaders
 from .assimp import _assimp_loaders
 from .threemf import _three_loaders
 from .openctm import _ctm_loaders
-from .xml_based import _xml_loaders
-from .binvox import _binvox_loaders
-from .xyz import _xyz_loaders
+from .threedxml import _threedxml_loaders
 
 
 try:
@@ -37,13 +39,13 @@ except BaseException as E:
 
 def mesh_formats():
     """
-    Get a list of mesh formats
+    Get a list of mesh formats available to load.
 
     Returns
     -----------
     loaders : list
-        Extensions of available mesh loaders
-        i.e. 'stl', 'ply', etc.
+      Extensions of available mesh loaders,
+      i.e. 'stl', 'ply', etc.
     """
     return list(mesh_loaders.keys())
 
@@ -625,11 +627,12 @@ mesh_loaders.update(_misc_loaders)
 mesh_loaders.update(_stl_loaders)
 mesh_loaders.update(_ctm_loaders)
 mesh_loaders.update(_ply_loaders)
-mesh_loaders.update(_xml_loaders)
 mesh_loaders.update(_obj_loaders)
 mesh_loaders.update(_off_loaders)
 mesh_loaders.update(_collada_loaders)
 mesh_loaders.update(_gltf_loaders)
+mesh_loaders.update(_xaml_loaders)
+mesh_loaders.update(_threedxml_loaders)
 mesh_loaders.update(_three_loaders)
 mesh_loaders.update(_xyz_loaders)
 
diff --git a/trimesh/exchange/xml_based.py b/trimesh/exchange/threedxml.py
similarity index 66%
rename from trimesh/exchange/xml_based.py
rename to trimesh/exchange/threedxml.py
index 7fd62195..6d7b4808 100644
--- a/trimesh/exchange/xml_based.py
+++ b/trimesh/exchange/threedxml.py
@@ -1,11 +1,15 @@
+"""
+threedxml.py
+-------------
+
+Load 3DXML files, a scene format from Solidworks.
+"""
 import numpy as np
 
-import collections
 import json
+import collections
 
 from .. import util
-from .. import visual
-from .. import transformations as tf
 
 try:
     import networkx as nx
@@ -16,145 +20,6 @@ except BaseException as E:
     nx = ExceptionModule(E)
 
 
-def load_XAML(file_obj, *args, **kwargs):
-    """
-    Load a 3D XAML file.
-
-    Parameters
-    ----------
-    file_obj : file object
-                Open, containing XAML file
-
-    Returns
-    ----------
-    result : dict
-                kwargs for a trimesh constructor, including:
-                vertices:       (n,3) np.float64, points in space
-                faces:          (m,3) np.int64, indices of vertices
-                face_colors:    (m,4) np.uint8, RGBA colors
-                vertex_normals: (n,3) np.float64, vertex normals
-    """
-    def element_to_color(element):
-        """
-        Turn an XML element into a (4,) np.uint8 RGBA color
-        """
-        if element is None:
-            return visual.DEFAULT_COLOR
-        hexcolor = int(element.attrib['Color'].replace('#', ''), 16)
-        opacity = float(element.attrib['Opacity'])
-        rgba = [(hexcolor >> 16) & 0xFF,
-                (hexcolor >> 8) & 0xFF,
-                (hexcolor & 0xFF),
-                opacity * 0xFF]
-        rgba = np.array(rgba, dtype=np.uint8)
-        return rgba
-
-    def element_to_transform(element):
-        """
-        Turn an XML element into a (4,4) np.float64
-        transformation matrix.
-        """
-        try:
-            matrix = next(element.iter(
-                tag=ns + 'MatrixTransform3D')).attrib['Matrix']
-            matrix = np.array(matrix.split(),
-                              dtype=np.float64).reshape((4, 4)).T
-            return matrix
-        except StopIteration:
-            # this will be raised if the MatrixTransform3D isn't in the passed
-            # elements tree
-            return np.eye(4)
-
-    # read the file and parse XML
-    file_data = file_obj.read()
-    root = etree.XML(file_data)
-
-    # the XML namespace
-    ns = root.tag.split('}')[0] + '}'
-
-    # the linked lists our results are going in
-    vertices = []
-    faces = []
-    colors = []
-    normals = []
-
-    # iterate through the element tree
-    # the GeometryModel3D tag contains a material and geometry
-    for geometry in root.iter(tag=ns + 'GeometryModel3D'):
-
-        # get the diffuse and specular colors specified in the material
-        color_search = './/{ns}{color}Material/*/{ns}SolidColorBrush'
-        diffuse = geometry.find(color_search.format(ns=ns,
-                                                    color='Diffuse'))
-        specular = geometry.find(color_search.format(ns=ns,
-                                                     color='Specular'))
-
-        # convert the element into a (4,) np.uint8 RGBA color
-        diffuse = element_to_color(diffuse)
-        specular = element_to_color(specular)
-
-        # to get the final transform of a component we'll have to traverse
-        # all the way back to the root node and save transforms we find
-        current = geometry
-        transforms = collections.deque()
-        # when the root node is reached its parent will be None and we stop
-        while current is not None:
-            # element.find will only return elements that are direct children
-            # of the current element as opposed to element.iter,
-            # which will return any depth of child
-            transform_element = current.find(ns + 'ModelVisual3D.Transform')
-            if transform_element is not None:
-                # we are traversing the tree backwards, so append new
-                # transforms to the left of the deque
-                transforms.appendleft(element_to_transform(transform_element))
-            # we are going from the lowest level of the tree to the highest
-            # this avoids having to traverse any branches that don't have
-            # geometry
-            current = current.getparent()
-
-        if len(transforms) == 0:
-            # no transforms in the tree mean an identity matrix
-            transform = np.eye(4)
-        elif len(transforms) == 1:
-            # one transform in the tree we can just use
-            transform = transforms.pop()
-        else:
-            # multiple transforms we apply all of them in order
-            transform = util.multi_dot(transforms)
-
-        # iterate through the contained mesh geometry elements
-        for g in geometry.iter(tag=ns + 'MeshGeometry3D'):
-            c_normals = np.array(g.attrib['Normals'].replace(',', ' ').split(),
-                                 dtype=np.float64).reshape((-1, 3))
-
-            c_vertices = np.array(
-                g.attrib['Positions'].replace(
-                    ',', ' ').split(), dtype=np.float64).reshape(
-                (-1, 3))
-            # bake in the transform as we're saving
-            c_vertices = tf.transform_points(c_vertices, transform)
-
-            c_faces = np.array(
-                g.attrib['TriangleIndices'].replace(
-                    ',', ' ').split(), dtype=np.int64).reshape(
-                (-1, 3))
-
-            # save data to a sequence
-            vertices.append(c_vertices)
-            faces.append(c_faces)
-            colors.append(np.tile(diffuse, (len(c_faces), 1)))
-            normals.append(c_normals)
-
-    # compile the results into clean numpy arrays
-    result = dict()
-    result['vertices'], result['faces'] = util.append_faces(vertices,
-                                                            faces)
-    result['face_colors'] = np.vstack(colors)
-    result['vertex_normals'] = np.vstack(normals)
-
-    return result
-
-
 def load_3DXML(file_obj, *args, **kwargs):
     """
     Load a 3DXML scene into kwargs. 3DXML is a CAD format
@@ -369,9 +234,8 @@ def load_3DXML(file_obj, *args, **kwargs):
     # paths from the root to a geometry to generate the tree of the scene
     paths = []
     for geometry_id in geometries.keys():
-        paths.extend(nx.all_simple_paths(graph,
-                                         source=root_id,
-                                         target=geometry_id))
+        paths.extend(nx.all_simple_paths(
+            graph, source=root_id, target=geometry_id))
 
     # the name of the root frame
     root_name = references[root_id]['name']
@@ -446,8 +310,6 @@ def print_element(element):
 
 try:
     from lxml import etree
-    _xml_loaders = {'xaml': load_XAML,
-                    '3dxml': load_3DXML}
-
+    _threedxml_loaders = {'3dxml': load_3DXML}
 except ImportError:
-    _xml_loaders = {}
+    _threedxml_loaders = {}
diff --git a/trimesh/exchange/xaml.py b/trimesh/exchange/xaml.py
new file mode 100644
index 00000000..ec28fdb5
--- /dev/null
+++ b/trimesh/exchange/xaml.py
@@ -0,0 +1,163 @@
+"""
+xaml.py
+---------
+
+Load 3D XAMl files, an export option from Solidworks.
+"""
+import numpy as np
+
+import collections
+
+from .. import util
+from .. import visual
+from .. import transformations as tf
+
+try:
+    import networkx as nx
+except BaseException as E:
+    # create a dummy module which will raise the ImportError
+    # or other exception only when someone tries to use networkx
+    from ..exceptions import ExceptionModule
+    nx = ExceptionModule(E)
+
+
+def load_XAML(file_obj, *args, **kwargs):
+    """
+    Load a 3D XAML file.
+
+    Parameters
+    ----------
+    file_obj : file object
+      Open XAML file.
+
+    Returns
+    ----------
+    result : dict
+      Kwargs for a Trimesh constructor.
+    """
+    def element_to_color(element):
+        """
+        Turn an XML element into a (4,) np.uint8 RGBA color
+        """
+        if element is None:
+            return visual.DEFAULT_COLOR
+        hexcolor = int(element.attrib['Color'].replace('#', ''), 16)
+        opacity = float(element.attrib['Opacity'])
+        rgba = [(hexcolor >> 16) & 0xFF,
+                (hexcolor >> 8) & 0xFF,
+                (hexcolor & 0xFF),
+                opacity * 0xFF]
+        rgba = np.array(rgba, dtype=np.uint8)
+        return rgba
+
+    def element_to_transform(element):
+        """
+        Turn an XML element into a (4,4) np.float64
+        transformation matrix.
+        """
+        try:
+            matrix = next(element.iter(
+                tag=ns + 'MatrixTransform3D')).attrib['Matrix']
+            matrix = np.array(matrix.split(),
+                              dtype=np.float64).reshape((4, 4)).T
+            return matrix
+        except StopIteration:
+            # this will be raised if the MatrixTransform3D isn't in the passed
+            # elements tree
+            return np.eye(4)
+
+    # read the file and parse XML
+    file_data = file_obj.read()
+    root = etree.XML(file_data)
+
+    # the XML namespace
+    ns = root.tag.split('}')[0] + '}'
+
+    # the linked lists our results are going in
+    vertices = []
+    faces = []
+    colors = []
+    normals = []
+
+    # iterate through the element tree
+    # the GeometryModel3D tag contains a material and geometry
+    for geometry in root.iter(tag=ns + 'GeometryModel3D'):
+
+        # get the diffuse and specular colors specified in the material
+        color_search = './/{ns}{color}Material/*/{ns}SolidColorBrush'
+        diffuse = geometry.find(color_search.format(ns=ns,
+                                                    color='Diffuse'))
+        specular = geometry.find(color_search.format(ns=ns,
+                                                     color='Specular'))
+
+        # convert the element into a (4,) np.uint8 RGBA color
+        diffuse = element_to_color(diffuse)
+        specular = element_to_color(specular)
+
+        # to get the final transform of a component we'll have to traverse
+        # all the way back to the root node and save transforms we find
+        current = geometry
+        transforms = collections.deque()
+        # when the root node is reached its parent will be None and we stop
+        while current is not None:
+            # element.find will only return elements that are direct children
+            # of the current element as opposed to element.iter,
+            # which will return any depth of child
+            transform_element = current.find(ns + 'ModelVisual3D.Transform')
+            if transform_element is not None:
+                # we are traversing the tree backwards, so append new
+                # transforms to the left of the deque
+                transforms.appendleft(element_to_transform(transform_element))
+            # we are going from the lowest level of the tree to the highest
+            # this avoids having to traverse any branches that don't have
+            # geometry
+            current = current.getparent()
+
+        if len(transforms) == 0:
+            # no transforms in the tree mean an identity matrix
+            transform = np.eye(4)
+        elif len(transforms) == 1:
+            # one transform in the tree we can just use
+            transform = transforms.pop()
+        else:
+            # multiple transforms we apply all of them in order
+            transform = util.multi_dot(transforms)
+
+        # iterate through the contained mesh geometry elements
+        for g in geometry.iter(tag=ns + 'MeshGeometry3D'):
+            c_normals = np.array(g.attrib['Normals'].replace(',', ' ').split(),
+                                 dtype=np.float64).reshape((-1, 3))
+
+            c_vertices = np.array(
+                g.attrib['Positions'].replace(
+                    ',', ' ').split(), dtype=np.float64).reshape(
+                (-1, 3))
+            # bake in the transform as we're saving
+            c_vertices = tf.transform_points(c_vertices, transform)
+
+            c_faces = np.array(
+                g.attrib['TriangleIndices'].replace(
+                    ',', ' ').split(), dtype=np.int64).reshape(
+                (-1, 3))
+
+            # save data to a sequence
+            vertices.append(c_vertices)
+            faces.append(c_faces)
+            colors.append(np.tile(diffuse, (len(c_faces), 1)))
+            normals.append(c_normals)
+
+    # compile the results into clean numpy arrays
+    result = dict()
+    result['vertices'], result['faces'] = util.append_faces(vertices,
+                                                            faces)
+    result['face_colors'] = np.vstack(colors)
+    result['vertex_normals'] = np.vstack(normals)
+
+    return result
+
+
+try:
+    from lxml import etree
+    _xaml_loaders = {'xaml': load_XAML}
+except ImportError:
+    _xaml_loaders = {}
diff --git a/trimesh/path/traversal.py b/trimesh/path/traversal.py
index 5b913b97..f4417b85 100644
--- a/trimesh/path/traversal.py
+++ b/trimesh/path/traversal.py
@@ -299,18 +299,15 @@ class PathSample:
         if offset < constants.tol_path.merge:
             truncated = self._points[:position + 1]
         else:
-            vector = util.unitize(np.diff(self._points[np.arange(2) + position],
-                                          axis=0).reshape(-1))
+            vector = util.unitize(np.diff(
+                self._points[np.arange(2) + position],
+                axis=0).reshape(-1))
             vector *= offset
             endpoint = self._points[position] + vector
             truncated = np.vstack((self._points[:position + 1],
                                    endpoint))
-
-        assert (
-            util.row_norm(
-                np.diff(
-                    truncated,
-                    axis=0)).sum() -
+        assert (util.row_norm(np.diff(
+            truncated, axis=0)).sum() -
             distance) < constants.tol_path.merge
 
         return truncated
diff --git a/trimesh/scene/scene.py b/trimesh/scene/scene.py
index cd0820b6..d8b546c1 100644
--- a/trimesh/scene/scene.py
+++ b/trimesh/scene/scene.py
@@ -16,7 +16,7 @@ from ..parent import Geometry3D
 from . import cameras
 from . import lighting
 
-from .transforms import TransformForest
+from .transforms import SceneGraph
 
 
 class Scene(Geometry3D):
@@ -59,7 +59,7 @@ class Scene(Geometry3D):
         self.geometry = collections.OrderedDict()
 
         # create a new graph
-        self.graph = TransformForest(base_frame=base_frame)
+        self.graph = SceneGraph(base_frame=base_frame)
 
         # create our cache
         self._cache = caching.Cache(id_function=self.md5)
@@ -77,7 +77,9 @@ class Scene(Geometry3D):
 
         self.camera = camera
         self.lights = lights
-        self.camera_transform = camera_transform
+
+        if camera is not None and camera_transform is not None:
+            self.camera_transform = camera_transform
 
     def apply_transform(self, transform):
         """
@@ -231,21 +233,34 @@ class Scene(Geometry3D):
         return util.md5_object(self._hashable())
 
     def crc(self):
+        """
+        Get a CRC of the current geometry and graph information.
+
+        Returns
+        ---------
+        crc : int
+          Hash of current graph and geometry.
+        """
         return caching.crc32(self._hashable())
 
     def _hashable(self):
-        hashes = [self.graph.md5()]
-        for g in self.geometry.values():
-            if hasattr(g, 'md5'):
-                hashes.append(g.md5())
-            elif hasattr(g, 'tostring'):
-                hashes.append(str(hash(g.tostring())))
-            else:
-                # try to just straight up hash
-                # this may raise errors
-                hashes.append(str(hash(g)))
-        hashable = ''.join(sorted(hashes)).encode('utf-8')
-        return hashable
+        """
+        Return information about scene which is hashable.
+
+        Returns
+        ---------
+        hashable : str
+          Data which can be hashed.
+        """
+        # start with the last modified time of the scene graph
+        hashable = [self.graph.modified()]
+        # crc is an abstractmethod for all Geometry3D
+        # objects so everything should really have it
+        hashable.extend(str(i.crc()) for i in
+                        self.geometry.values()
+                        if hasattr(i, 'crc'))
+        # crc requires bytes so encode to utf-8
+        return ':'.join(sorted(hashable)).encode('utf-8')
 
     @property
     def is_empty(self):
@@ -579,7 +594,7 @@ class Scene(Geometry3D):
     @property
     def camera_transform(self):
         """
-        Get camera transform in the base frame
+        Get camera transform in the base frame.
 
         Returns
         -------
@@ -588,6 +603,18 @@ class Scene(Geometry3D):
         """
         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
@@ -617,20 +644,6 @@ class Scene(Geometry3D):
                    transformations.translation_from_matrix(transform))
         return origins, vectors, pixels
 
-    @camera_transform.setter
-    def camera_transform(self, camera_transform):
-        """
-        Set the camera transform in the base frame
-
-        Parameters
-        ----------
-        camera_transform : (4, 4) float
-          Camera transform in the base frame
-        """
-        if camera_transform is None:
-            return
-        self.graph[self.camera.name] = camera_transform
-
     @property
     def camera(self):
         """
diff --git a/trimesh/scene/transforms.py b/trimesh/scene/transforms.py
index 92dee878..0df2b1e8 100644
--- a/trimesh/scene/transforms.py
+++ b/trimesh/scene/transforms.py
@@ -1,36 +1,39 @@
+import uuid
 import copy
-import time
 
 import numpy as np
 import collections
 
 from .. import util
 from .. import caching
-from .. import exceptions
 from .. import transformations
 
-try:
-    import networkx as nx
-    _ForestParent = nx.DiGraph
-except BaseException as E:
-    # create a dummy module which will raise the ImportError
-    # or other exception only when someone tries to use networkx
-    nx = exceptions.ExceptionModule(E)
-    _ForestParent = object
 
+class SceneGraph(object):
+    """
+    Hold data about positions and instances of geometry
+    in a scene. This includes a forest (i.e. multi-root tree)
+    of transforms and information on which node is the base
+    frame, and which geometries are affiliated with which
+    nodes.
+    """
 
-class TransformForest(object):
     def __init__(self, base_frame='world'):
+        """
+        Create a scene graph, holding homogenous transformation
+        matrices and instance information about geometry.
+
+        Parameters
+        -----------
+        base_frame : any
+          The root node transforms will be positioned from.
+        """
         # a graph structure, subclass of networkx DiGraph
         self.transforms = EnforcedForest()
         # hashable, the base or root frame
         self.base_frame = base_frame
-
-        # save paths, keyed with tuple (from, to)
-        self._paths = {}
         # cache transformation matrices keyed with tuples
-        self._updated = str(np.random.random())
-        self._cache = caching.Cache(self.md5)
+        self._cache = caching.Cache(self.modified)
 
     def update(self, frame_to, frame_from=None, **kwargs):
         """
@@ -57,95 +60,152 @@ class TransformForest(object):
           Geometry object name, e.g. 'mesh_0'
         extras: dictionary
           Optional metadata attached to the new frame
-          (expors to glTF node 'extras').
+          (exports to glTF node 'extras').
         """
-
-        self._updated = str(np.random.random())
-        self._cache.clear()
-
         # if no frame specified, use base frame
         if frame_from is None:
             frame_from = self.base_frame
-        # convert various kwargs to a single matrix
-        matrix = kwargs_to_matrix(**kwargs)
 
-        # create the edge attributes
-        attr = {'matrix': matrix, 'time': time.time()}
-        # pass through geometry to edge attribute
-        if 'geometry' in kwargs:
-            attr['geometry'] = kwargs['geometry']
+        # pass through
+        attr = {k: v for k, v in kwargs.items()
+                if k in {'geometry', 'extras'}}
+        # convert various kwargs to a single matrix
+        attr['matrix'] = kwargs_to_matrix(**kwargs)
 
-        if 'extras' in kwargs:
-            attr['extras'] = kwargs['extras']
+        # add the edges for the transforms
+        # will return if it changed anything
+        if self.transforms.add_edge(
+                frame_from, frame_to, **attr):
+            # clear all cached matrices by setting
+            # modified hash to a random string
+            self._modified = str(uuid.uuid4())
 
-        # add the edges
-        changed = self.transforms.add_edge(frame_from,
-                                           frame_to,
-                                           **attr)
         # set the node attribute with the geometry information
         if 'geometry' in kwargs:
-            nx.set_node_attributes(
-                self.transforms,
-                name='geometry',
-                values={frame_to: kwargs['geometry']})
+            self.transforms.node_data[
+                frame_to]['geometry'] = kwargs['geometry']
+
+    def get(self, frame_to, frame_from=None):
+        """
+        Get the transform from one frame to another.
+
+        Parameters
+        ------------
+        frame_to : hashable
+          Node name, usually a string (eg 'mesh_0')
+        frame_from : hashable
+          Node name, usually a string (eg 'world').
+          If None it will be set to self.base_frame
+
+        Returns
+        ----------
+        transform : (4, 4) float
+          Homogeneous transformation matrix
+
+        Raises
+        -----------
+        ValueError
+          If the frames aren't connected.
+        """
+
+        # use base frame if not specified
+        if frame_from is None:
+            frame_from = self.base_frame
 
-        # if the edge update changed our structure
-        # dump our cache of shortest paths
-        if changed:
-            self._paths = {}
+        # look up transform to see if we have it already
+        key = (frame_from, frame_to)
+        if key in self._cache:
+            return self._cache[key]
+
+        # get the geometry at the final node if any
+        geometry = self.transforms.node_data[
+            frame_to].get('geometry')
+
+        # get a local reference to edge data
+        edge_data = self.transforms.edge_data
+
+        if frame_from == frame_to:
+            # if we're going from ourself return identity
+            matrix = np.eye(4)
+        elif key in edge_data:
+            # if the path is just an edge return early
+            matrix = edge_data[key]['matrix']
+        else:
+            # we have a 3+ node path
+            # get the path from the forest always going from
+            # parent -> child -> child
+            path = self.transforms.shortest_path(
+                frame_from, frame_to)
+            # collect a homogenous transform for each edge
+            matrices = [edge_data[(u, v)]['matrix'] for u, v in
+                        zip(path[:-1], path[1:])]
+            # multiply matrices into single transform
+            matrix = util.multi_dot(matrices)
 
-    def md5(self):
-        return self._updated
+        # store the result
+        self._cache[key] = (matrix, geometry)
+
+        return matrix, geometry
+
+    def modified(self):
+        """
+        Return the last time stamp data was modified.
+        """
+        if hasattr(self, '_modified'):
+            return self._modified
+        return '0.0'
 
     def copy(self):
         """
-        Return a copy of the current TransformForest
+        Return a copy of the current TransformForest.
 
         Returns
         ------------
         copied : TransformForest
+          Copy of current object.
         """
-        copied = TransformForest()
-        copied.base_frame = copy.deepcopy(self.base_frame)
-        copied.transforms = copy.deepcopy(self.transforms)
+        return copy.deepcopy(self)
 
-        return copied
-
-    def to_flattened(self, base_frame=None):
-        """
-        Export the current transform graph as a flattened
+    def to_flattened(self):
         """
-        if base_frame is None:
-            base_frame = self.base_frame
+        Export the current transform graph with all
+        transforms baked into world->instance.
 
+        Returns
+        ---------
+        flat : dict
+          Keyed {node : {transform, geometry}
+        """
         flat = {}
+        base_frame = self.base_frame
         for node in self.nodes:
             if node == base_frame:
                 continue
-            transform, geometry = self.get(
+            # get the matrix and geometry name
+            matrix, geometry = self.get(
                 frame_to=node, frame_from=base_frame)
-            flat[node] = {
-                'transform': transform.tolist(),
-                'geometry': geometry
-            }
+            # store matrix as list rather than numpy array
+            flat[node] = {'transform': matrix.tolist(),
+                          'geometry': geometry}
+
         return flat
 
     def to_gltf(self, scene, mesh_index=None):
         """
-        Export a transforms as the 'nodes' section of a GLTF dict.
-        Flattens tree.
+        Export a transforms as the 'nodes' section of the
+        GLTF header dict.
 
         Parameters
         ------------
         scene : trimesh.Scene
-          Scene with geoemtry
+          Scene with geometry.
         mesh_index : dict or None
           Mapping { key in scene.geometry : int }
 
         Returns
         --------
         gltf : dict
-          with 'nodes' referencing a list of dicts
+          With 'nodes' referencing a list of dicts
         """
 
         if mesh_index is None:
@@ -154,40 +214,43 @@ class TransformForest(object):
             mesh_index = {name: i for i, name
                           in enumerate(scene.geometry.keys())}
 
-        # shortcut to graph
+        # get graph information into local scope before loop
         graph = self.transforms
         # get the stored node data
-        node_data = dict(graph.nodes(data=True))
+        node_data = graph.node_data
+        edge_data = graph.edge_data
+        base_frame = self.base_frame
 
         # list of dict, in gltf format
         # start with base frame as first node index
-        result = [{'name': self.base_frame}]
+        result = [{'name': base_frame}]
         # {node name : node index in gltf}
-        lookup = {self.base_frame: 0}
+        lookup = {base_frame: 0}
 
         # collect the nodes in order
         for node in node_data.keys():
-            if node == self.base_frame:
+            if node == base_frame:
                 continue
+            # assign the index to the node-name lookup
             lookup[node] = len(result)
+            # populate a result at the correct index
             result.append({'name': node})
 
+        # get generated properties outside of loop
         # does the scene have a defined camera to export
         has_camera = scene.has_camera
+        children = graph.children
 
         # then iterate through to collect data
         for info in result:
             # name of the scene node
             node = info['name']
-            # store children as indexes
 
-            try:
-                children = [lookup[k] for k in graph[node].keys() if k in lookup]
-            except KeyError:
-                continue
+            # get the original node names for children
+            childs = children.get(node, [])
+            if len(childs) > 0:
+                info['children'] = [lookup[k] for k in childs]
 
-            if len(children) > 0:
-                info['children'] = children
             # if we have a mesh store by index
             if 'geometry' in node_data[node]:
                 mesh_key = node_data[node]['geometry']
@@ -197,24 +260,23 @@ class TransformForest(object):
             if has_camera and node == scene.camera.name:
                 info['camera'] = 0
 
-            try:
-                # try to ignore KeyError and StopIteration
-                # parent-child transform is stored in child
-                parent = next(iter(graph.predecessors(node)))
-                # get the (4, 4) homogeneous transform
-                matrix = graph.get_edge_data(parent, node)['matrix']
-                # only include matrix if it is not an identity matrix
+            if node != base_frame:
+                parent = graph.parents[node]
+
+                # get the matrix from this edge
+                matrix = edge_data[(parent, node)]['matrix']
+                # only include if it's not an identify matrix
                 if np.abs(matrix - np.eye(4)).max() > 1e-5:
                     info['matrix'] = matrix.T.reshape(-1).tolist()
-            except BaseException:
-                continue
 
-            try:
-                extras = graph.get_edge_data(parent, node)['extras']
+                # if an extra was stored on this edge
+                extras = edge_data[(parent, node)].get('extras')
                 if extras:
+                    # convert any numpy arrays to lists
+                    extras.update(
+                        {k: v.tolist() for k, v in extras.items()
+                         if hasattr(v, 'tolist')})
                     info['extras'] = extras
-            except BaseException:
-                pass
 
         return {'nodes': result}
 
@@ -232,22 +294,22 @@ class TransformForest(object):
         # save cleaned edges
         export = []
         # loop through (node, node, edge attributes)
-        for edge in nx.to_edgelist(self.transforms):
-            a, b, attr = edge
+        for edge, attr in self.transforms.edge_data.items():
+            # node indexes from edge
+            a, b = edge
             # geometry is a node property but save it to the
             # edge so we don't need two dictionaries
-            try:
-                b_attr = self.transforms.nodes[b]
-            except BaseException:
-                # networkx 1.X API
-                b_attr = self.transforms.node[b]
+            b_attr = self.transforms.node_data[b]
+            # make sure we're not stomping on original
+            attr_new = attr.copy()
             # apply node geometry to edge attributes
             if 'geometry' in b_attr:
-                attr['geometry'] = b_attr['geometry']
-            # save the matrix as a float list
-            attr['matrix'] = np.asanyarray(
-                attr['matrix'], dtype=np.float64).tolist()
-            export.append((a, b, attr))
+                attr_new['geometry'] = b_attr['geometry']
+            # convert any numpy arrays to regular lists
+            attr_new.update(
+                {k: v.tolist() for k, v in attr_new.items()
+                 if hasattr(v, 'tolist')})
+            export.append((a, b, attr_new))
         return export
 
     def from_edgelist(self, edges, strict=True):
@@ -258,10 +320,10 @@ class TransformForest(object):
         Parameters
         -------------
         edgelist : (n,) tuples
-            (node_a, node_b, {key: value})
+          Keyed (node_a, node_b, {key: value})
         strict : bool
-            If true, raise a ValueError when a
-            malformed edge is passed in a tuple.
+          If True raise a ValueError when a
+          malformed edge is passed in a tuple.
         """
         # loop through each edge
         for edge in edges:
@@ -283,7 +345,7 @@ class TransformForest(object):
         Parameters
         -------------
         edgelist : (n,) tuples
-            (node_a, node_b, {key: value})
+          Structured (node_a, node_b, {key: value})
         """
         self.from_edgelist(edgelist, strict=True)
 
@@ -295,10 +357,9 @@ class TransformForest(object):
         Returns
         -------------
         nodes : (n,) array
-          All node names
+          All node names.
         """
-        nodes = list(self.transforms.nodes())
-        return nodes
+        return self.transforms.nodes
 
     @caching.cache_decorator
     def nodes_geometry(self):
@@ -310,31 +371,31 @@ class TransformForest(object):
         nodes_geometry : (m,) array
           Node names which have geometry associated
         """
-        nodes = [n for n, attr in
-                 self.transforms.nodes(data=True)
-                 if 'geometry' in attr]
-        return nodes
+        return [n for n, attr in
+                self.transforms.node_data.items()
+                if 'geometry' in attr]
 
     @caching.cache_decorator
     def geometry_nodes(self):
         """
-        Which nodes have this geometry?
+        Which nodes have this geometry? Inverse
+        of `nodes_geometry`.
 
         Returns
         ------------
         geometry_nodes : dict
-          Geometry name : (n,) node names
+          Keyed {geometry_name : node name}
         """
         res = collections.defaultdict(list)
-        for node, attr in self.transforms.nodes(data=True):
+        for node, attr in self.transforms.node_data.items():
             if 'geometry' in attr:
                 res[attr['geometry']].append(node)
         return res
 
     def remove_geometries(self, geometries):
         """
-        Remove the reference for specified geometries from nodes
-        without deleting the node.
+        Remove the reference for specified geometries
+        from nodes without deleting the node.
 
         Parameters
         ------------
@@ -348,7 +409,7 @@ class TransformForest(object):
 
         # remove the geometry reference from the node without deleting nodes
         # this lets us keep our cached paths, and will not screw up children
-        for node, attrib in self.transforms.nodes(data=True):
+        for node, attrib in self.transforms.node_data.items():
             if 'geometry' in attrib and attrib['geometry'] in geometries:
                 attrib.pop('geometry')
 
@@ -357,94 +418,8 @@ class TransformForest(object):
         # nodes_geometry: if this becomes not true change this to clear!
         self._cache.cache.pop('nodes_geometry', None)
 
-    def get(self, frame_to, frame_from=None):
-        """
-        Get the transform from one frame to another, assuming they are connected
-        in the transform tree.
-
-        If the frames are not connected a NetworkXNoPath error will be raised.
-
-        Parameters
-        ------------
-        frame_to : hashable
-          Node name, usually a string (eg 'mesh_0')
-        frame_from : hashable
-          Node name, usually a string (eg 'world').
-          If None it will be set to self.base_frame
-
-        Returns
-        ----------
-        transform : (4, 4) float
-          Homogeneous transformation matrix
-        """
-
-        if frame_from is None:
-            frame_from = self.base_frame
-
-        # look up transform to see if we have it already
-        cache_key = (frame_from, frame_to)
-        cached = self._cache[cache_key]
-        if cached is not None:
-            return cached
-
-        # get the path in the graph
-        path = self._get_path(frame_from, frame_to)
-
-        # collect transforms along the path
-        transforms = []
-
-        for i in range(len(path) - 1):
-            # get the matrix and edge direction
-            data, direction = self.transforms.get_edge_data_direction(
-                path[i], path[i + 1])
-            matrix = data['matrix']
-            if direction < 0:
-                matrix = np.linalg.inv(matrix)
-            transforms.append(matrix)
-        # do all dot products at the end
-        if len(transforms) == 0:
-            transform = np.eye(4)
-        elif len(transforms) == 1:
-            transform = np.asanyarray(transforms[0], dtype=np.float64)
-        else:
-            transform = util.multi_dot(transforms)
-
-        try:
-            attr = self.transforms.nodes[frame_to]
-        except BaseException:
-            # networkx 1.X API
-            attr = self.transforms.node[frame_to]
-
-        if 'geometry' in attr:
-            geometry = attr['geometry']
-        else:
-            geometry = None
-
-        self._cache[cache_key] = (transform, geometry)
-
-        return transform, geometry
-
-    def show(self):
-        """
-        Plot the graph layout of the scene.
-        """
-        import matplotlib.pyplot as plt
-        nx.draw(self.transforms, with_labels=True)
-        plt.show()
-
-    def to_svg(self):
-        """
-        """
-        from ..graph import graph_to_svg
-        return graph_to_svg(self.transforms)
-
     def __contains__(self, key):
-        try:
-            return key in self.transforms.nodes
-        except BaseException:
-            # networkx 1.X API
-            util.log.warning('upgrade networkx to version 2.X!')
-            return key in self.transforms.nodes()
+        return key in self.transforms.node_data
 
     def __getitem__(self, key):
         return self.get(key)
@@ -457,142 +432,188 @@ class TransformForest(object):
 
     def clear(self):
         self.transforms = EnforcedForest()
-        self._paths = {}
-        self._updated = str(np.random.random())
         self._cache.clear()
 
-    def _get_path(self, frame_from, frame_to):
+
+class EnforcedForest(object):
+    """
+    A simple forest graph data structure: every node
+    is allowed to have exactly one parent. This makes
+    traversal and implementation much simpler than a
+    full graph data type; by storing only one parent
+    reference, it enforces the structure for "free."
+    """
+
+    def __init__(self, **kwargs):
+        # since every node can have only one parent
+        # this data structure transparently enforces
+        # the forest data structure without checks
+        # a dict {child : parent}
+        self.parents = {}
+
+        # store data for a particular edge keyed by tuple
+        # {(u, v) : data }
+        self.edge_data = {}
+        # {u: data}
+        self.node_data = {}
+
+        # if multiple calls are made for the same path
+        # but the connectivity hasn't changed return cached
+        self._cache = {}
+
+    def add_edge(self, u, v, **kwargs):
         """
-        Find a path between two frames, either from cached paths or
-        from the transform graph.
+        Add an edge to the forest cleanly.
 
         Parameters
-        ------------
-        frame_from: a frame key, usually a string
-                    eg, 'world'
-        frame_to:   a frame key, usually a string
-                    eg, 'mesh_0'
+        -----------
+        u : any
+          Hashable node key.
+        v : any
+          Hashable node key.
+        kwargs : dict
+           Stored as (u, v) edge data.
 
         Returns
-        ----------
-        path: (n) list of frame keys
-              eg, ['mesh_finger', 'mesh_hand', 'world']
+        --------
+        changed : bool
+          Return if this operation changed anything.
+       """
+        # topology has changed so clear cache
+        if (u, v) not in self.edge_data:
+            self._cache = {}
+        else:
+            # check to see if matrix and geometry are identical
+            edge = self.edge_data[(u, v)]
+            if (np.allclose(kwargs.get('matrix', np.eye(4)),
+                            edge.get('matrix', np.eye(4)))
+                and (edge.get('geometry') ==
+                     kwargs.get('geometry'))):
+                return False
+
+        # store a parent reference for traversal
+        self.parents[v] = u
+        # store kwargs for edge data keyed with tuple
+        self.edge_data[(u, v)] = kwargs
+        # set empty node data
+        self.node_data.update({u: {}, v: {}})
+
+        return True
+
+    def shortest_path(self, u, v):
         """
-        # store paths keyed as a tuple
-        key = (frame_from, frame_to)
-        if key not in self._paths:
-            # get the actual shortest paths
-            path = self.transforms.shortest_path_undirected(
-                frame_from, frame_to)
-            # store path to avoid recomputing
-            self._paths[key] = path
-            return path
-        return self._paths[key]
-
-
-class EnforcedForest(_ForestParent):
-    """
-    A subclass of networkx.DiGraph that will raise an error if an
-    edge is added which would make the DiGraph not a forest or tree.
-    """
-
-    def __init__(self, *args, **kwargs):
-        self.flags = {'strict': False, 'assert_forest': False}
+        Find the shortest path beween `u` and `v`.
 
-        for k, v in self.flags.items():
-            if k in kwargs:
-                self.flags[k] = bool(kwargs[k])
-                kwargs.pop(k, None)
+        Note that it will *always* be ordered from
+        root direction to leaf direction, so `u` may
+        be either the first *or* last element.
 
-        super(self.__class__, self).__init__(*args, **kwargs)
-        # keep a second parallel but undirected copy of the graph
-        # all of the networkx methods for turning a directed graph
-        # into an undirected graph are quite slow so we do minor bookkeeping
-        self._undirected = nx.Graph()
+        Parameters
+        -----------
+        u : any
+          Hashable node key.
+        v : any
+          Hashable node key.
 
-    def add_edge(self, u, v, *args, **kwargs):
-        changed = False
+        Returns
+        -----------
+        path : (n,)
+          Path between `u` and `v`
+        """
+        # see if we've already computed this path
         if u == v:
-            if self.flags['strict']:
-                raise ValueError('Edge must be between two unique nodes!')
-            return changed
-        elif self._undirected.has_edge(u, v):
-            self.remove_edges_from([[u, v], [v, u]])
-        elif len(self.nodes()) > 0:
-            try:
-                path = nx.shortest_path(self._undirected, u, v)
-                if self.flags['strict']:
-                    raise ValueError(
-                        'Multiple edge path exists between nodes!')
-                self.disconnect_path(path)
-                changed = True
-            except (nx.NetworkXError, nx.NetworkXNoPath, nx.NetworkXException):
-                pass
-        self._undirected.add_edge(u, v)
-        super(self.__class__, self).add_edge(u, v, *args, **kwargs)
-
-        if self.flags['assert_forest']:
-            # this is quite slow but makes very sure structure is correct
-            # so is mainly used for testing
-            assert nx.is_forest(nx.Graph(self))
-
-        return changed
-
-    def add_edges_from(self, *args, **kwargs):
-        raise ValueError('EnforcedTree requires add_edge method to be used!')
-
-    def add_path(self, *args, **kwargs):
-        raise ValueError('EnforcedTree requires add_edge method to be used!')
-
-    def remove_edge(self, *args, **kwargs):
-        super(self.__class__, self).remove_edge(*args, **kwargs)
-        self._undirected.remove_edge(*args, **kwargs)
-
-    def remove_edges_from(self, *args, **kwargs):
-        super(self.__class__, self).remove_edges_from(*args, **kwargs)
-        self._undirected.remove_edges_from(*args, **kwargs)
-
-    def disconnect_path(self, path):
-        ebunch = np.array([[path[0], path[1]]])
-        ebunch = np.vstack((ebunch, np.fliplr(ebunch)))
-        self.remove_edges_from(ebunch)
-
-    def shortest_path_undirected(self, u, v):
-        try:
-            path = nx.shortest_path(self._undirected, u, v)
-        except BaseException as E:
-            raise E
-        return path
-
-    def get_edge_data_direction(self, u, v):
-        if self.has_edge(u, v):
-            direction = 1
-        elif self.has_edge(v, u):
-            direction = -1
-        else:
-            raise ValueError('Edge does not exist!')
-        data = self.get_edge_data(*[u, v][::direction])
-        return data, direction
+            #  the path between itself is an edge case
+            return []
+        elif (u, v) in self._cache:
+            # return the same path for either direction
+            return self._cache[(u, v)]
+        elif (v, u) in self._cache:
+            return self._cache[(v, u)]
+
+        # local reference to parent dict for performance
+        parents = self.parents
+        # store both forward and backwards traversal
+        forward = [u]
+        backward = [v]
+
+        # cap iteration to number of total nodes
+        for _ in range(len(parents) + 1):
+            # store the parent both forwards and backwards
+            forward.append(parents.get(forward[-1]))
+            backward.append(parents.get(backward[-1]))
+            if forward[-1] == v:
+                self._cache[(u, v)] = forward
+                return forward
+            elif backward[-1] == u:
+                # return reversed path
+                backward = backward[::-1]
+                self._cache[(u, v)] = backward
+                return backward
+            elif forward[-1] is None and backward[-1] is None:
+                raise ValueError('No path between nodes!')
+        raise ValueError('Iteration limit exceeded!')
+
+    @property
+    def nodes(self):
+        """
+        Get a set of every node.
 
+        Returns
+        -----------
+        nodes : set
+          Every node currently stored.
+        """
+        return set(self.node_data.keys())
 
-def kwargs_to_matrix(**kwargs):
+    @property
+    def children(self):
+        """
+        Get the children of each node.
+
+        Returns
+        ----------
+        children : dict
+          Keyed {node : [child, child, ...]}
+        """
+        child = collections.defaultdict(list)
+        # append children to parent references
+        # skip self-references to avoid a node loop
+        [child[v].append(u) for u, v in
+         self.parents.items() if u != v]
+
+        # return as a vanilla dict
+        return dict(child)
+
+
+def kwargs_to_matrix(
+        matrix=None,
+        quaternion=None,
+        translation=None,
+        axis=None,
+        angle=None,
+        **kwargs):
     """
-    Turn a set of keyword arguments into a transformation matrix.
+    Take multiple keyword arguments and parse them
+    into a homogenous transformation matrix.
+
+    Returns
+    ---------
+    matrix : (4, 4) float
+      Homogenous transformation matrix.
     """
-    if 'matrix' in kwargs:
-        # a matrix takes precedence over other options
-        matrix = np.asanyarray(kwargs['matrix'], dtype=np.float64)
-    elif 'quaternion' in kwargs:
-        matrix = transformations.quaternion_matrix(kwargs['quaternion'])
-    elif ('axis' in kwargs) and ('angle' in kwargs):
-        matrix = transformations.rotation_matrix(kwargs['angle'],
-                                                 kwargs['axis'])
+    if matrix is not None:
+        # a matrix takes immediate precedence over other options
+        return np.array(matrix, dtype=np.float64)
+    elif quaternion is not None:
+        result = transformations.quaternion_matrix(quaternion)
+    elif axis is not None and angle is not None:
+        matrix = transformations.rotation_matrix(angle, axis)
     else:
         matrix = np.eye(4)
 
-    if 'translation' in kwargs:
-        # translation can be used in conjunction with any of the methods of
-        # specifying transforms. In the case a matrix and translation are passed,
-        # we add the translations together rather than picking one.
-        matrix[:3, 3] += kwargs['translation']
-    return matrix
+    if translation is not None:
+        # translation can be used in conjunction with any
+        # of the methods specifying transforms
+        result[:3, 3] += translation
+
+    return result
diff --git a/trimesh/version.py b/trimesh/version.py
index a27c99c4..5fadf310 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.9.14'
+__version__ = '3.9.15'
diff --git a/trimesh/viewer/windowed.py b/trimesh/viewer/windowed.py
index 5010ebb5..9111387b 100644
--- a/trimesh/viewer/windowed.py
+++ b/trimesh/viewer/windowed.py
@@ -11,7 +11,6 @@ import collections
 import numpy as np
 
 import pyglet
-import pyglet.gl as gl
 
 from .trackball import Trackball
 
@@ -23,6 +22,8 @@ from ..transformations import translation_matrix
 
 pyglet.options['shadow_window'] = False
 
+import pyglet.gl as gl  # NOQA
+
 # smooth only when fewer faces than this
 _SMOOTH_MAX_FACES = 100000
 
diff --git a/trimesh/visual/material.py b/trimesh/visual/material.py
index 2c52eeb7..a3fb8620 100644
--- a/trimesh/visual/material.py
+++ b/trimesh/visual/material.py
@@ -10,6 +10,7 @@ import numpy as np
 from . import color
 from .. import util
 from .. import grouping
+from .. import exceptions
 
 
 class Material(object):
@@ -338,7 +339,11 @@ def empty_material(color=None):
     material : SimpleMaterial
       Image is a a one pixel RGB
     """
-    from PIL import Image
+    try:
+        from PIL import Image
+    except BaseException as E:
+        return exceptions.ExceptionModule(E)
+
     if color is None or np.shape(color) not in ((3,), (4,)):
         color = np.array([255, 255, 255], dtype=np.uint8)
     else:


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index e7b4bb16..2ebb804f 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -42,6 +42,10 @@ jobs:
       uses: actions/setup-python@v2
       with:
         python-version: ${{ matrix.python-version }}
+    - name: Test a minimal install
+      run: |
+        pip install .
+        python tests/test_minimal.py
     - name: Install Pytest
       run: |
         pip install pytest pytest-cov
diff --git a/tests/test_gltf.py b/tests/test_gltf.py
index bdb4bcda..c42d0a86 100644
--- a/tests/test_gltf.py
+++ b/tests/test_gltf.py
@@ -147,6 +147,7 @@ class GLTFTest(g.unittest.TestCase):
         # export it as a a GLB file
         export = original.export(file_type='glb')
         validate_glb(export)
+
         kwargs = g.trimesh.exchange.gltf.load_glb(
             g.trimesh.util.wrap_as_stream(export))
         # roundtrip it
@@ -469,7 +470,7 @@ class GLTFTest(g.unittest.TestCase):
         files = r.export(None, "gltf")
         gltf_data = files["model.gltf"]
         assert 'test_value' in gltf_data.decode('utf8')
-        edge_data = r.graph.transforms.get_edge_data("world", "Sphere1")
+        edge_data = r.graph.transforms.edge_data[("world", "Sphere1")]
         assert edge_data['extras'] == test_metadata
 
     def test_read_scene_extras(self):
diff --git a/tests/test_minimal.py b/tests/test_minimal.py
new file mode 100644
index 00000000..a0657f07
--- /dev/null
+++ b/tests/test_minimal.py
@@ -0,0 +1,48 @@
+"""
+test_minimal.py
+--------------
+
+Test things that should work with a *minimal* trimesh install.
+
+
+"""
+import os
+
+import unittest
+import trimesh
+
+# the path of the current directory
+_pwd = os.path.dirname(
+    os.path.abspath(os.path.expanduser(__file__)))
+# the absolute path for our reference models
+_mwd = os.path.abspath(
+    os.path.join(_pwd, '..', 'models'))
+
+
+def get_mesh(file_name, **kwargs):
+    return trimesh.load(os.path.join(_mwd, file_name),
+                        **kwargs)
+
+
+class MinimalTest(unittest.TestCase):
+
+    def test_load(self):
+        # kinds of files we should be able to
+        # load even with a minimal install
+        kinds = 'stl ply obj off gltf glb'.split()
+
+        for file_name in os.listdir(_mwd):
+            ext = os.path.splitext(file_name)[-1].lower()[1:]
+            if ext not in kinds:
+                continue
+
+            print(file_name)
+            m = get_mesh(file_name)
+            if isinstance(m, trimesh.Trimesh):
+                assert len(m.face_adjacency.shape) == 2
+                assert len(m.vertices.shape) == 2
+
+
+if __name__ == '__main__':
+    trimesh.util.attach_to_log()
+    unittest.main()
diff --git a/tests/test_scene.py b/tests/test_scene.py
index ea484260..49d0b9a7 100644
--- a/tests/test_scene.py
+++ b/tests/test_scene.py
@@ -92,16 +92,18 @@ class SceneTests(g.unittest.TestCase):
                     # try exporting the scene as a dict
                     # then make sure json can serialize it
                     e = g.json.dumps(s.export(file_type=export_format))
-
                     # reconstitute the dict into a scene
                     r = g.trimesh.load(g.json.loads(e))
 
                     # make sure the extents are similar before and after
-                    assert g.np.allclose(g.np.product(s.extents),
-                                         g.np.product(r.extents))
+                    assert g.np.allclose(
+                        g.np.product(s.extents),
+                        g.np.product(r.extents))
 
+                # move the scene to origin
                 s.rezero()
-                assert (g.np.abs(s.centroid) < 1e-3).all()
+                # if our cache dump was bad this will fail
+                assert g.np.allclose(s.centroid, 0, atol=1e-5)
 
                 # make sure explode doesn't crash
                 s.explode()
@@ -359,14 +361,22 @@ class GraphTests(g.unittest.TestCase):
             scene = g.trimesh.Scene()
             scene.add_geometry(g.trimesh.creation.box())
 
+            mod = [scene.graph.modified()]
             scene.set_camera()
+            mod.append(scene.graph.modified())
+            assert mod[-1] != mod[-2]
+
             assert not g.np.allclose(
                 scene.camera_transform,
                 g.np.eye(4))
             scene.camera_transform = g.np.eye(4)
+            mod.append(scene.graph.modified())
+            assert mod[-1] != mod[-2]
+
             assert g.np.allclose(
                 scene.camera_transform,
                 g.np.eye(4))
+            assert mod[-1] != mod[-2]
 
 
 if __name__ == '__main__':