Repository: mikedh/trimesh
Pull request #2328 (2024-12-03 17:50:09)

Release: Procrustes Weights
- release #2313
- release #2327



Patch:
diff --git a/examples/nearest.ipynb b/examples/nearest.ipynb
index 12674e91..655f0618 100644
--- a/examples/nearest.ipynb
+++ b/examples/nearest.ipynb
@@ -75,7 +75,7 @@
     "# create a scene containing the mesh and two sets of points\n",
     "scene = trimesh.Scene([mesh, cloud_original, cloud_close])\n",
     "\n",
-    "# show the scene wusing\n",
+    "# show the scene we are using\n",
     "scene.show()"
    ]
   }
diff --git a/pyproject.toml b/pyproject.toml
index eac53178..3609dcd9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -120,7 +120,6 @@ test_more = [
     "matplotlib",
     "pymeshlab",
     "triangle",
-    "ipython",
 ]
 
 # interfaces.gmsh will be dropped Jan 2025
diff --git a/trimesh/__init__.py b/trimesh/__init__.py
index ec75e39c..40d3b85c 100644
--- a/trimesh/__init__.py
+++ b/trimesh/__init__.py
@@ -54,7 +54,6 @@ from .exchange.load import (
     load_mesh,
     load_path,
     load_remote,
-    load_scene,
 )
 
 # geometry objects
@@ -109,7 +108,6 @@ __all__ = [
     "load_mesh",
     "load_path",
     "load_remote",
-    "load_scene",
     "nsphere",
     "path",
     "permutate",
diff --git a/trimesh/__main__.py b/trimesh/__main__.py
index 8240cda8..3f988994 100644
--- a/trimesh/__main__.py
+++ b/trimesh/__main__.py
@@ -30,8 +30,7 @@ def main():
     args = parser.parse_args()
 
     if args.file_name is None:
-        parser.print_help()
-        return
+        scene = None
     else:
         scene = load(args.file_name)
 
diff --git a/trimesh/base.py b/trimesh/base.py
index c59bde5d..dc6b8b6b 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -2553,7 +2553,7 @@ class Trimesh(Geometry3D):
           A number between 0.0 and 1.0 for how much
         face_count
           Target number of faces desired in the resulting mesh.
-        agression
+        aggression
           An integer between `0` and `10`, the scale being roughly
           `0` is "slow and good" and `10` being "fast and bad."
 
diff --git a/trimesh/creation.py b/trimesh/creation.py
index 2687a79f..97df23a5 100644
--- a/trimesh/creation.py
+++ b/trimesh/creation.py
@@ -67,7 +67,7 @@ def revolve(
       passed will be a full revolution (`angle = 2*pi`)
     cap
       If not a full revolution (`0.0 < angle < 2 * pi`)
-      and cap is True attempt to add a tesselated cap.
+      and cap is True attempt to add a tessellated cap.
     sections
       Number of sections result should have
       If not specified default is 32 per revolution
diff --git a/trimesh/exchange/gltf.py b/trimesh/exchange/gltf.py
index 29962894..63217877 100644
--- a/trimesh/exchange/gltf.py
+++ b/trimesh/exchange/gltf.py
@@ -15,9 +15,9 @@ import numpy as np
 from .. import rendering, resources, transformations, util, visual
 from ..caching import hash_fast
 from ..constants import log, tol
-from ..resolvers import ResolverLike, ZipResolver
+from ..resolvers import Resolver, ZipResolver
 from ..scene.cameras import Camera
-from ..typed import NDArray, Optional, Stream
+from ..typed import Mapping, NDArray, Optional, Stream, Union
 from ..util import triangle_strips_to_faces, unique_name
 from ..visual.gloss import specular_to_pbr
 
@@ -50,6 +50,9 @@ _default_material = {
     }
 }
 
+# we can accept dict resolvers
+ResolverLike = Union[Resolver, Mapping]
+
 # GL geometry modes
 _GL_LINES = 1
 _GL_POINTS = 0
@@ -452,22 +455,22 @@ def load_glb(
     return kwargs
 
 
-def _uri_to_bytes(uri: str, resolver: ResolverLike) -> bytes:
+def _uri_to_bytes(uri, resolver):
     """
     Take a URI string and load it as a
     a filename or as base64.
 
     Parameters
     --------------
-    uri
+    uri : string
       Usually a filename or something like:
       "data:object/stuff,base64,AABA112A..."
-    resolver
+    resolver : trimesh.visual.Resolver
       A resolver to load referenced assets
 
     Returns
     ---------------
-    data
+    data : bytes
       Loaded data from URI
     """
     # see if the URI has base64 data
diff --git a/trimesh/exchange/load.py b/trimesh/exchange/load.py
index e45ba912..b6951dc7 100644
--- a/trimesh/exchange/load.py
+++ b/trimesh/exchange/load.py
@@ -1,6 +1,5 @@
 import json
 import os
-from dataclasses import dataclass
 
 import numpy as np
 
@@ -10,8 +9,8 @@ from ..exceptions import ExceptionWrapper
 from ..parent import Geometry
 from ..points import PointCloud
 from ..scene.scene import Scene, append_scenes
-from ..typed import Loadable, Optional, Stream
-from ..util import log
+from ..typed import Dict, List, Loadable, Optional, Union
+from ..util import log, now
 from . import misc
 from .binvox import _binvox_loaders
 from .cascade import _cascade_loaders
@@ -32,8 +31,8 @@ try:
 except BaseException as E:
     # save a traceback to see why path didn't import
     load_path = ExceptionWrapper(E)
-
     # no path formats available
+
     def path_formats() -> set:
         return set()
 
@@ -72,100 +71,13 @@ def available_formats() -> set:
 def load(
     file_obj: Loadable,
     file_type: Optional[str] = None,
-    resolver: Optional[resolvers.ResolverLike] = None,
+    resolver: Union[resolvers.Resolver, Dict, None] = None,
     force: Optional[str] = None,
-    allow_remote: bool = False,
-    **kwargs,
-) -> Geometry:
-    """
-
-    For new code the typed load functions `trimesh.load_scene` or `trimesh.load_mesh`
-    are recommended over `trimesh.load` which is a backwards-compatibility wrapper
-    that mimics the behavior of the old function and can return any geometry type.
-
-    Parameters
-    -----------
-    file_obj : str, or file- like object
-      The source of the data to be loadeded
-    file_type: str
-      What kind of file type do we have (eg: 'stl')
-    resolver : trimesh.visual.Resolver
-      Object to load referenced assets like materials and textures
-    force : None or str
-      For 'mesh': try to coerce scenes into a single mesh
-      For 'scene': try to coerce everything into a scene
-    allow_remote
-      If True allow this load call to work on a remote URL.
-    kwargs : dict
-      Passed to geometry __init__
-
-    Returns
-    ---------
-    geometry : Trimesh, Path2D, Path3D, Scene
-      Loaded geometry as trimesh classes
-    """
-
-    # call the most general loading case into a `Scene`.
-    loaded = load_scene(
-        file_obj=file_obj,
-        file_type=file_type,
-        resolver=resolver,
-        allow_remote=allow_remote,
-        **kwargs,
-    )
-
-    if force == "mesh":
-        # new code should use `load_mesh` for this
-        log.debug(
-            "`trimesh.load(force='mesh')` is a compatibility wrapper for `trimesh.load_mesh`"
-        )
-        return loaded.to_mesh()
-    elif force == "scene":
-        # new code should use `load_scene` for this
-        log.debug(
-            "`trimesh.load(force='scene')` is a compatibility wrapper for `trimesh.load_scene`"
-        )
-        return loaded
-
-    # else:
-    #    log.debug(
-    #        "For new code the typed load functions `trimesh.load_scene` or `trimesh.load_mesh` "
-    #        + "are recommended over `trimesh.load` which is a backwards-compatibility wrapper "
-    #        + "that mimics the behavior of the old function and can return any geometry type."
-    #    )
-
-    ###########################################
-    # we are matching deprecated behavior here!
-    # matching old behavior you should probably use `load_scene`
-    if len(loaded.geometry) == 1:
-        kind = loaded.metadata.get("file_type", file_type)
-        geom = next(iter(loaded.geometry.values()))
-        if (kind not in {"glb", "gltf"} and isinstance(geom, PointCloud)) or kind in {
-            "obj",
-            "stl",
-            "ply",
-            "svg",
-            "binvox",
-            "xaml",
-            "dxf",
-            "off",
-        }:
-            return geom
-
-    return loaded
-
-
-def load_scene(
-    file_obj: Loadable,
-    file_type: Optional[str] = None,
-    resolver: Optional[resolvers.ResolverLike] = None,
-    allow_remote: bool = False,
     **kwargs,
-) -> Scene:
+) -> Union[Geometry, List[Geometry]]:
     """
-    Load geometry into the `trimesh.Scene` container. This may contain
-    any `parent.Geometry` object, including `Trimesh`, `Path2D`, `Path3D`,
-    or a `PointCloud`.
+    Load a mesh or vectorized path into objects like
+    Trimesh, Path2D, Path3D, Scene
 
     Parameters
     -----------
@@ -178,8 +90,6 @@ def load_scene(
     force : None or str
       For 'mesh': try to coerce scenes into a single mesh
       For 'scene': try to coerce everything into a scene
-    allow_remote
-      If True allow this load call to work on a remote URL.
     kwargs : dict
       Passed to geometry __init__
 
@@ -188,77 +98,77 @@ def load_scene(
     geometry : Trimesh, Path2D, Path3D, Scene
       Loaded geometry as trimesh classes
     """
+    # check to see if we're trying to load something
+    # that is already a native trimesh Geometry subclass
+    if isinstance(file_obj, Geometry):
+        log.info("Load called on %s object, returning input", file_obj.__class__.__name__)
+        return file_obj
 
-    # parse all possible values of file objects into simple types
-    arg = _parse_file_args(
-        file_obj=file_obj,
-        file_type=file_type,
-        resolver=resolver,
-        allow_remote=allow_remote,
-        **kwargs,
-    )
+    # parse the file arguments into clean loadable form
+    (
+        file_obj,  # file- like object
+        file_type,  # str, what kind of file
+        metadata,  # dict, any metadata from file name
+        opened,  # bool, did we open the file ourselves
+        resolver,  # object to load referenced resources
+    ) = _parse_file_args(file_obj=file_obj, file_type=file_type, resolver=resolver)
 
     try:
-        if arg.file_type in path_formats():
+        if isinstance(file_obj, dict):
+            # if we've been passed a dict treat it as kwargs
+            kwargs.update(file_obj)
+            loaded = load_kwargs(kwargs)
+        elif file_type in path_formats():
             # path formats get loaded with path loader
-            loaded = load_path(
-                file_obj=arg.file_obj,
-                file_type=arg.file_type,
-                metadata=arg.metadata,
-                **kwargs,
-            )
-        elif arg.file_type in ["svg", "dxf"]:
-            # call the dummy function to raise the import error
-            # this prevents the exception from being super opaque
-            load_path()
-        elif isinstance(arg.file_obj, dict):
-            loaded = _load_kwargs(arg.file_obj)
-        elif arg.file_type in mesh_loaders:
+            loaded = load_path(file_obj, file_type=file_type, **kwargs)
+        elif file_type in mesh_loaders:
             # mesh loaders use mesh loader
-
-            loaded = _load_kwargs(
-                mesh_loaders[arg.file_type](
-                    file_obj=arg.file_obj,
-                    file_type=arg.file_type,
-                    resolver=arg.resolver,
-                    metadata=arg.metadata,
-                    **kwargs,
-                )
-            )
-        elif arg.file_type in compressed_loaders:
+            loaded = load_mesh(file_obj, file_type=file_type, resolver=resolver, **kwargs)
+        elif file_type in compressed_loaders:
             # for archives, like ZIP files
-            loaded = _load_compressed(arg.file_obj, file_type=arg.file_type, **kwargs)
-        elif arg.file_type in voxel_loaders:
-            loaded = voxel_loaders[arg.file_type](
-                file_obj=arg.file_obj,
-                file_type=arg.file_type,
-                resolver=arg.resolver,
-                **kwargs,
+            loaded = load_compressed(file_obj, file_type=file_type, **kwargs)
+        elif file_type in voxel_loaders:
+            loaded = voxel_loaders[file_type](
+                file_obj, file_type=file_type, resolver=resolver, **kwargs
             )
         else:
-            raise ValueError(f"file_type: '{arg.file_type}' not supported")
-
+            if file_type in ["svg", "dxf"]:
+                # call the dummy function to raise the import error
+                # this prevents the exception from being super opaque
+                load_path()
+            else:
+                raise ValueError(f"File type: {file_type} not supported")
     finally:
-        # if we opened the file ourselves from a file name
         # close any opened files even if we crashed out
-        if arg.was_opened:
-            arg.file_obj.close()
+        if opened:
+            file_obj.close()
+
+    # add load metadata ('file_name') to each loaded geometry
+    for i in util.make_sequence(loaded):
+        i.metadata.update(metadata)
 
-    if not isinstance(loaded, Scene):
-        loaded = Scene(loaded)
+    # if we opened the file in this function ourselves from a
+    # file name clean up after ourselves by closing it
+    if opened:
+        file_obj.close()
 
-    # add the "file_path" information to the overall scene metadata
-    if "metadata" not in kwargs:
-        loaded.metadata.update(arg.metadata)
-    # add the load path metadata to every geometry
-    # [g.metadata.update(arg.metadata) for g in loaded.geometry.values()]
+    # combine a scene into a single mesh
+    if force == "mesh" and isinstance(loaded, Scene):
+        return util.concatenate(loaded.dump())
+    if force == "scene" and not isinstance(loaded, Scene):
+        return Scene(loaded)
 
     return loaded
 
 
-def load_mesh(*args, **kwargs) -> Trimesh:
+def load_mesh(
+    file_obj: Loadable,
+    file_type: Optional[str] = None,
+    resolver: Union[resolvers.Resolver, Dict, None] = None,
+    **kwargs,
+) -> Union[Geometry, List[Geometry]]:
     """
-    Load a file into a Trimesh object.
+    Load a mesh file into a Trimesh object.
 
     Parameters
     -----------
@@ -274,10 +184,46 @@ def load_mesh(*args, **kwargs) -> Trimesh:
     mesh
       Loaded geometry data.
     """
-    return load_scene(*args, **kwargs).to_mesh()
 
+    # parse the file arguments into clean loadable form
+    (
+        file_obj,  # file-like object
+        file_type,  # str: what kind of file
+        metadata,  # dict: any metadata from file name
+        opened,  # bool: did we open the file ourselves
+        resolver,  # Resolver: to load referenced resources
+    ) = _parse_file_args(file_obj=file_obj, file_type=file_type, resolver=resolver)
+
+    try:
+        # make sure we keep passed kwargs to loader
+        # but also make sure loader keys override passed keys
+        loader = mesh_loaders[file_type]
+        tic = now()
+        results = loader(file_obj, file_type=file_type, resolver=resolver, **kwargs)
+        if not isinstance(results, list):
+            results = [results]
+
+        loaded = []
+        for result in results:
+            kwargs.update(result)
+            loaded.append(load_kwargs(kwargs))
+            loaded[-1].metadata.update(metadata)
+
+        # todo : remove this
+        if len(loaded) == 1:
+            loaded = loaded[0]
+
+        # show the repr for loaded, loader used, and time
+        log.debug(f"loaded {loaded!s} using `{loader.__name__}` in {now() - tic:0.4f}s")
+    finally:
+        # if we failed to load close file
+        if opened:
+            file_obj.close()
+
+    return loaded
 
-def _load_compressed(file_obj, file_type=None, resolver=None, mixed=False, **kwargs):
+
+def load_compressed(file_obj, file_type=None, resolver=None, mixed=False, **kwargs):
     """
     Given a compressed archive load all the geometry that
     we can from it.
@@ -299,74 +245,86 @@ def _load_compressed(file_obj, file_type=None, resolver=None, mixed=False, **kwa
     """
 
     # parse the file arguments into clean loadable form
-    arg = _parse_file_args(file_obj=file_obj, file_type=file_type, resolver=resolver)
+    (
+        file_obj,  # file- like object
+        file_type,  # str, what kind of file
+        metadata,  # dict, any metadata from file name
+        opened,  # bool, did we open the file ourselves
+        resolver,  # object to load referenced resources
+    ) = _parse_file_args(file_obj=file_obj, file_type=file_type, resolver=resolver)
 
-    # store loaded geometries as a list
-    geometries = []
+    try:
+        # a dict of 'name' : file-like object
+        files = util.decompress(file_obj=file_obj, file_type=file_type)
+        # store loaded geometries as a list
+        geometries = []
 
-    # so loaders can access textures/etc
-    archive = util.decompress(file_obj=arg.file_obj, file_type=arg.file_type)
-    resolver = resolvers.ZipResolver(archive)
-
-    # try to save the files with meaningful metadata
-    archive_name = arg.metadata.get("file_path", "archive")
-    if archive_name is not None:
-        meta_archive = {
-            "file_name": os.path.basename(archive_name),
-            "file_path": os.path.join(archive_name),
-        }
-    else:
-        meta_archive = {}
+        # so loaders can access textures/etc
+        resolver = resolvers.ZipResolver(files)
 
-    # populate our available formats
-    if mixed:
-        available = available_formats()
-    else:
-        # all types contained in ZIP archive
-        contains = {util.split_extension(n).lower() for n in resolver.keys()}
-        # if there are no mesh formats available
-        if contains.isdisjoint(mesh_formats()):
-            available = path_formats()
+        # try to save the files with meaningful metadata
+        if "file_path" in metadata:
+            archive_name = metadata["file_path"]
         else:
-            available = mesh_formats()
+            archive_name = "archive"
 
-    for file_name, file_obj in archive.items():
-        try:
-            # only load formats that we support
-            compressed_type = util.split_extension(file_name).lower()
-
-            # if file has metadata type include it
-            if compressed_type in ("yaml", "yml"):
-                import yaml
-
-                continue
-                meta_archive[file_name] = yaml.safe_load(file_obj)
-            elif compressed_type == "json":
-                import json
-
-                meta_archive[file_name] = json.loads(file_obj)
-                continue
-            elif compressed_type not in available:
-                # don't raise an exception, just try the next one
-                continue
-
-            # load the individual geometry
-            geometries.append(
-                load_scene(
-                    file_obj=file_obj,
+        # populate our available formats
+        if mixed:
+            available = available_formats()
+        else:
+            # all types contained in ZIP archive
+            contains = {util.split_extension(n).lower() for n in files.keys()}
+            # if there are no mesh formats available
+            if contains.isdisjoint(mesh_formats()):
+                available = path_formats()
+            else:
+                available = mesh_formats()
+
+        meta_archive = {}
+        for name, data in files.items():
+            try:
+                # only load formats that we support
+                compressed_type = util.split_extension(name).lower()
+
+                # if file has metadata type include it
+                if compressed_type in "yaml":
+                    import yaml
+
+                    meta_archive[name] = yaml.safe_load(data)
+                elif compressed_type in "json":
+                    import json
+
+                    meta_archive[name] = json.loads(data)
+
+                if compressed_type not in available:
+                    # don't raise an exception, just try the next one
+                    continue
+                # store the file name relative to the archive
+                metadata["file_name"] = archive_name + "/" + os.path.basename(name)
+                # load the individual geometry
+                loaded = load(
+                    file_obj=data,
                     file_type=compressed_type,
                     resolver=resolver,
+                    metadata=metadata,
                     **kwargs,
                 )
-            )
 
-        except BaseException:
-            log.debug("failed to load file in zip", exc_info=True)
+                # some loaders return multiple geometries
+                if util.is_sequence(loaded):
+                    # if the loader has returned a list of meshes
+                    geometries.extend(loaded)
+                else:
+                    # if the loader has returned a single geometry
+                    geometries.append(loaded)
+            except BaseException:
+                log.debug("failed to load file in zip", exc_info=True)
 
-    # if we opened the file in this function
-    # clean up after ourselves
-    if arg.was_opened:
-        arg.file_obj.close()
+    finally:
+        # if we opened the file in this function
+        # clean up after ourselves
+        if opened:
+            file_obj.close()
 
     # append meshes or scenes into a single Scene object
     result = append_scenes(geometries)
@@ -378,34 +336,61 @@ def _load_compressed(file_obj, file_type=None, resolver=None, mixed=False, **kwa
     return result
 
 
-def load_remote(url: str, **kwargs) -> Scene:
+def load_remote(url, **kwargs):
     """
     Load a mesh at a remote URL into a local trimesh object.
 
-    This is a thin wrapper around:
-      `trimesh.load_scene(file_obj=url, allow_remote=True, **kwargs)`
+    This must be called explicitly rather than automatically
+    from trimesh.load to ensure users don't accidentally make
+    network requests.
 
     Parameters
     ------------
-    url
+    url : string
       URL containing mesh file
-    **kwargs
-      Passed to `load_scene`
+    **kwargs : passed to `load`
 
     Returns
     ------------
     loaded : Trimesh, Path, Scene
       Loaded result
     """
-    return load_scene(file_obj=url, allow_remote=True, **kwargs)
+    # import here to keep requirement soft
+    import httpx
+
+    # download the mesh
+    response = httpx.get(url, follow_redirects=True)
+    response.raise_for_status()
+
+    # wrap as file object
+    file_obj = util.wrap_as_stream(response.content)
+
+    # so loaders can access textures/etc
+    resolver = resolvers.WebResolver(url)
+
+    try:
+        # if we have a bunch of query parameters the type
+        # will be wrong so try to clean up the URL
+        # urllib is Python 3 only
+        import urllib
+
+        # remove the url-safe encoding then split off query params
+        file_type = urllib.parse.unquote(url).split("?", 1)[0].split("/")[-1].strip()
+    except BaseException:
+        # otherwise just use the last chunk of URL
+        file_type = url.split("/")[-1].split("?", 1)[0]
+
+    # actually load the data from the retrieved bytes
+    loaded = load(file_obj=file_obj, file_type=file_type, resolver=resolver, **kwargs)
+    return loaded
 
 
-def _load_kwargs(*args, **kwargs) -> Geometry:
+def load_kwargs(*args, **kwargs) -> Geometry:
     """
     Load geometry from a properly formatted dict or kwargs
     """
 
-    def handle_scene() -> Scene:
+    def handle_scene():
         """
         Load a scene from our kwargs.
 
@@ -415,7 +400,7 @@ def _load_kwargs(*args, **kwargs) -> Geometry:
         base_frame: str, base frame of graph
         """
         graph = kwargs.get("graph", None)
-        geometry = {k: _load_kwargs(v) for k, v in kwargs["geometry"].items()}
+        geometry = {k: load_kwargs(v) for k, v in kwargs["geometry"].items()}
 
         if graph is not None:
             scene = Scene()
@@ -458,7 +443,7 @@ def _load_kwargs(*args, **kwargs) -> Geometry:
 
         return scene
 
-    def handle_mesh() -> Trimesh:
+    def handle_mesh():
         """
         Handle the keyword arguments for a Trimesh object
         """
@@ -515,36 +500,21 @@ def _load_kwargs(*args, **kwargs) -> Geometry:
     for func, expected in handlers:
         if all(i in kwargs for i in expected):
             # all expected kwargs exist
-            return func()
-
-    raise ValueError(f"unable to determine type: {kwargs.keys()}")
-
-
-@dataclass
-class _FileArgs:
-    # a file-like object that can be accessed
-    file_obj: Optional[Stream]
-
-    # a cleaned file type string, i.e. "stl"
-    file_type: str
-
-    # any metadata generated from the file path
-    metadata: dict
-
-    # did we open `file_obj` ourselves?
-    was_opened: bool
+            handler = func
+            # exit the loop as we found one
+            break
+    else:
+        raise ValueError(f"unable to determine type: {kwargs.keys()}")
 
-    # a resolver for loading assets next to the file
-    resolver: Optional[resolvers.ResolverLike]
+    return handler()
 
 
 def _parse_file_args(
     file_obj: Loadable,
     file_type: Optional[str],
-    resolver: Optional[resolvers.ResolverLike] = None,
-    allow_remote: bool = False,
+    resolver: Union[None, Dict, resolvers.Resolver] = None,
     **kwargs,
-) -> _FileArgs:
+):
     """
     Given a file_obj and a file_type try to magically convert
     arguments to a file-like object and a lowercase string of
@@ -583,13 +553,21 @@ def _parse_file_args(
 
     Returns
     -----------
-    args
-      Populated `_FileArg` message
+    file_obj : file-like object
+      Contains data
+    file_type : str
+      Lower case of the type of file (eg 'stl', 'dae', etc)
+    metadata : dict
+      Any metadata gathered
+    opened : bool
+      Did we open the file or not
+    resolver : trimesh.visual.Resolver
+      Resolver to load other assets
     """
-
     metadata = {}
     opened = False
-    file_path = None
+    if "metadata" in kwargs and isinstance(kwargs["metadata"], dict):
+        metadata.update(kwargs["metadata"])
 
     if util.is_pathlib(file_obj):
         # convert pathlib objects to string
@@ -597,16 +575,15 @@ def _parse_file_args(
 
     if util.is_file(file_obj) and file_type is None:
         raise ValueError("file_type must be set for file objects!")
-
     if isinstance(file_obj, str):
         try:
             # os.path.isfile will return False incorrectly
             # if we don't give it an absolute path
-            file_path = os.path.abspath(os.path.expanduser(file_obj))
+            file_path = os.path.expanduser(file_obj)
+            file_path = os.path.abspath(file_path)
             exists = os.path.isfile(file_path)
         except BaseException:
             exists = False
-            file_path = None
 
         # file obj is a string which exists on filesystm
         if exists:
@@ -614,6 +591,8 @@ def _parse_file_args(
             if resolver is None:
                 resolver = resolvers.FilePathResolver(file_path)
             # save the file name and path to metadata
+            metadata["file_path"] = file_path
+            metadata["file_name"] = os.path.basename(file_obj)
             # if file_obj is a path that exists use extension as file_type
             if file_type is None:
                 file_type = util.split_extension(file_path, special=["tar.gz", "tar.bz2"])
@@ -622,23 +601,13 @@ def _parse_file_args(
             opened = True
         else:
             if "{" in file_obj:
-                # if a bracket is in the string it's probably straight JSON
+                # if a dict bracket is in the string, its probably a straight
+                # JSON
                 file_type = "json"
             elif "https://" in file_obj or "http://" in file_obj:
-                if not allow_remote:
-                    raise ValueError("unable to load URL with `allow_remote=False`")
-
-                import urllib
-
-                # remove the url-safe encoding and query params
-                file_type = util.split_extension(
-                    urllib.parse.unquote(file_obj).split("?", 1)[0].split("/")[-1].strip()
-                )
-                # create a web resolver to do the fetching and whatnot
-                resolver = resolvers.WebResolver(url=file_obj)
-                # fetch the base file
-                file_obj = util.wrap_as_stream(resolver.get_base())
-
+                # we've been passed a URL, warn to use explicit function
+                # and don't do network calls via magical pipeline
+                raise ValueError(f"use load_remote to load URL: {file_obj}")
             elif file_type is None:
                 raise ValueError(f"string is not a file: {file_obj}")
 
@@ -648,7 +617,9 @@ def _parse_file_args(
     if isinstance(file_type, str) and "." in file_type:
         # if someone has passed the whole filename as the file_type
         # use the file extension as the file_type
-        file_path = file_type
+        if "file_path" not in metadata:
+            metadata["file_path"] = file_type
+        metadata["file_name"] = os.path.basename(file_type)
         file_type = util.split_extension(file_type)
         if resolver is None and os.path.exists(file_type):
             resolver = resolvers.FilePathResolver(file_type)
@@ -656,16 +627,6 @@ def _parse_file_args(
     # all our stored extensions reference in lower case
     file_type = file_type.lower()
 
-    # if user passed in a metadata dict add it
-    if len(kwargs.get("metadata", {})) > 0:
-        metadata = kwargs["metadata"]
-    else:
-        metadata["file_type"] = file_type
-        if file_path is not None:
-            metadata.update(
-                {"file_path": file_path, "file_name": os.path.basename(file_path)}
-            )
-
     # if we still have no resolver try using file_obj name
     if (
         resolver is None
@@ -675,21 +636,15 @@ def _parse_file_args(
     ):
         resolver = resolvers.FilePathResolver(file_obj.name)
 
-    return _FileArgs(
-        file_obj=file_obj,
-        file_type=file_type,
-        metadata=metadata,
-        was_opened=opened,
-        resolver=resolver,
-    )
+    return file_obj, file_type, metadata, opened, resolver
 
 
 # loader functions for compressed extensions
 compressed_loaders = {
-    "zip": _load_compressed,
-    "tar.bz2": _load_compressed,
-    "tar.gz": _load_compressed,
-    "bz2": _load_compressed,
+    "zip": load_compressed,
+    "tar.bz2": load_compressed,
+    "tar.gz": load_compressed,
+    "bz2": load_compressed,
 }
 
 # map file_type to loader function
diff --git a/trimesh/exchange/obj.py b/trimesh/exchange/obj.py
index bff36248..841d183c 100644
--- a/trimesh/exchange/obj.py
+++ b/trimesh/exchange/obj.py
@@ -163,12 +163,10 @@ def load_obj(
             log.debug("faces have mixed data: using slow fallback!")
             faces, faces_tex, faces_norm = _parse_faces_fallback(face_lines)
 
-        if group_material and len(materials) > 1:
+        if group_material:
             name = material
-        elif current_object is not None:
-            name = current_object
         else:
-            name = kwargs.get("metadata", {}).get("file_name", "geometry")
+            name = current_object
 
         # ensure the name is always unique
         name = util.unique_name(name, geometry)
@@ -220,13 +218,9 @@ def load_obj(
                     faces, faces_norm, maintain_faces=maintain_order
                 )
             else:
-                # face_tex is None and
                 # generate the mask so we only include
                 # referenced vertices in every new mesh
-                if maintain_order:
-                    mask_v = np.ones(len(v), dtype=bool)
-                else:
-                    mask_v = np.zeros(len(v), dtype=bool)
+                mask_v = np.zeros(len(v), dtype=bool)
                 mask_v[faces] = True
 
                 # reconstruct the faces with the new vertex indices
@@ -275,11 +269,17 @@ def load_obj(
         # store geometry by name
         geometry[name] = mesh
 
+    if len(geometry) == 1:
+        # TODO : should this be removed to always return a scene?
+        return next(iter(geometry.values()))
+
     # add an identity transform for every geometry
     graph = [{"geometry": k, "frame_to": k} for k in geometry.keys()]
 
     # convert to scene kwargs
-    return {"geometry": geometry, "graph": graph}
+    result = {"geometry": geometry, "graph": graph}
+
+    return result
 
 
 def parse_mtl(mtl, resolver=None):
diff --git a/trimesh/graph.py b/trimesh/graph.py
index 8fa73964..0e2894c9 100644
--- a/trimesh/graph.py
+++ b/trimesh/graph.py
@@ -705,7 +705,8 @@ def edges_to_coo(edges, count=None, data=None):
     if data is None:
         data = np.ones(len(edges), dtype=bool)
 
-    return coo_matrix((data, edges.T), dtype=data.dtype, shape=(count, count))
+    matrix = coo_matrix((data, edges.T), dtype=data.dtype, shape=(count, count))
+    return matrix
 
 
 def neighbors(edges, max_index=None, directed=False):
diff --git a/trimesh/path/exchange/load.py b/trimesh/path/exchange/load.py
index e2805bf3..16dea51e 100644
--- a/trimesh/path/exchange/load.py
+++ b/trimesh/path/exchange/load.py
@@ -32,7 +32,7 @@ def load_path(file_obj, file_type: Optional[str] = None, **kwargs):
       Data as a native trimesh Path file_object
     """
     # avoid a circular import
-    from ...exchange.load import _load_kwargs
+    from ...exchange.load import load_kwargs
 
     if isinstance(file_type, str):
         # we accept full file names here so make sure we
@@ -72,14 +72,14 @@ def load_path(file_obj, file_type: Optional[str] = None, **kwargs):
         kwargs.update(misc.linestrings_to_path(file_obj))
     elif isinstance(file_obj, dict):
         # load as kwargs
-        return _load_kwargs(file_obj)
+        return load_kwargs(file_obj)
     elif util.is_sequence(file_obj):
         # load as lines in space
         kwargs.update(misc.lines_to_path(file_obj))
     else:
         raise ValueError("Not a supported object type!")
 
-    result = _load_kwargs(kwargs)
+    result = load_kwargs(kwargs)
     util.log.debug(f"loaded {result!s} in {util.now() - tic:0.4f}s")
 
     return result
diff --git a/trimesh/registration.py b/trimesh/registration.py
index 15dba178..e4a04748 100644
--- a/trimesh/registration.py
+++ b/trimesh/registration.py
@@ -209,10 +209,8 @@ def procrustes(
     Finds the transformation T mapping a to b which minimizes the
     square sum distances between Ta and b, also called the cost.
 
-    Optionally specify different weights for the points in a to minimize
-    the weighted square sum distances between Ta and b, which can
-    improve transformation robustness on noisy data if the points'
-    probability distribution is known.
+    Optionally filter the points in a and b via a binary weights array.
+    Non-uniform weights are also supported, but won't yield the optimal rotation.
 
     Parameters
     ----------
@@ -221,7 +219,12 @@ def procrustes(
     b : (n,3) float
       List of points in space
     weights : (n,) float
-      List of floats representing how much weight is assigned to each point of a
+      List of floats representing how much weight is assigned to each point.
+      Binary entries can be used to filter the arrays; normalization is not required.
+      Translation and scaling are adjusted according to the weighting.
+      Note, however, that this method does not yield the optimal rotation for
+      non-uniform weighting,
+      as this would require an iterative, nonlinear optimization approach.
     reflection : bool
       If the transformation is allowed reflections
     translation : bool
@@ -241,56 +244,51 @@ def procrustes(
       The cost of the transformation
     """
 
-    a = np.asanyarray(a, dtype=np.float64)
-    b = np.asanyarray(b, dtype=np.float64)
-    if not util.is_shape(a, (-1, 3)) or not util.is_shape(b, (-1, 3)):
+    a_original = np.asanyarray(a, dtype=np.float64)
+    b_original = np.asanyarray(b, dtype=np.float64)
+    if not util.is_shape(a_original, (-1, 3)) or not util.is_shape(b_original, (-1, 3)):
         raise ValueError("points must be (n,3)!")
-    if len(a) != len(b):
+    if len(a_original) != len(b_original):
         raise ValueError("a and b must contain same number of points!")
-    if weights is not None:
-        w = np.asanyarray(weights, dtype=np.float64)
-        if len(w) != len(a):
-            raise ValueError("weights must have same length as a and b!")
-        w_norm = (w / w.sum()).reshape((-1, 1))
+    # weights are set to uniform if not provided.
+    if weights is None:
+        weights = np.ones(len(a_original))
+    w = np.maximum(np.asanyarray(weights, dtype=np.float64), 0)
+    if len(w) != len(a):
+        raise ValueError("weights must have same length as a and b!")
+    w_norm = (w / w.sum()).reshape((-1, 1))
+
+    # All zero entries are removed from further computations.
+    # If weights is a binary array, the optimal solution can still be found by
+    # simply removing the zero entries.
+    nonzero_weights = w_norm[:, 0] > 0
+    a_nonzero = a_original[nonzero_weights]
+    b_nonzero = b_original[nonzero_weights]
+    w_norm = w_norm[nonzero_weights]
 
     # Remove translation component
     if translation:
-        # acenter is a weighted average of the individual points.
-        if weights is None:
-            acenter = a.mean(axis=0)
-        else:
-            acenter = (a * w_norm).sum(axis=0)
-        bcenter = b.mean(axis=0)
+        # centers are (weighted) averages of the individual points.
+        acenter = (a_nonzero * w_norm).sum(axis=0)
+        bcenter = (b_nonzero * w_norm).sum(axis=0)
     else:
-        acenter = np.zeros(a.shape[1])
-        bcenter = np.zeros(b.shape[1])
+        acenter = np.zeros(a_nonzero.shape[1])
+        bcenter = np.zeros(b_nonzero.shape[1])
 
     # Remove scale component
     if scale:
-        if weights is None:
-            ascale = np.sqrt(((a - acenter) ** 2).sum() / len(a))
-            # ascale is the square root of weighted average of the
-            # squared difference
-            # between each point and acenter.
-        else:
-            ascale = np.sqrt((((a - acenter) ** 2) * w_norm).sum())
-
-        bscale = np.sqrt(((b - bcenter) ** 2).sum() / len(b))
+        # scale is the square root of the (weighted) average of the
+        # squared difference between each point and the center.
+        ascale = np.sqrt((((a_nonzero - acenter) ** 2) * w_norm).sum())
+        bscale = np.sqrt((((b_nonzero - bcenter) ** 2) * w_norm).sum())
     else:
         ascale = 1
         bscale = 1
 
     # Use SVD to find optimal orthogonal matrix R
     # constrained to det(R) = 1 if necessary.
-    # w_mat is multiplied with the centered and scaled a, such that the points
-    # can be weighted differently.
 
-    if weights is None:
-        target = np.dot(((b - bcenter) / bscale).T, ((a - acenter) / ascale))
-    else:
-        target = np.dot(
-            ((b - bcenter) / bscale).T, ((a - acenter) / ascale) * w.reshape((-1, 1))
-        )
+    target = np.dot(((b_nonzero - bcenter) / bscale).T, ((a_nonzero - acenter) / ascale))
 
     u, _s, vh = np.linalg.svd(target)
 
@@ -308,9 +306,11 @@ def procrustes(
     matrix = np.vstack((matrix, np.array([0.0] * (a.shape[1]) + [1.0]).reshape(1, -1)))
 
     if return_cost:
-        transformed = transform_points(a, matrix)
-        # return the mean euclidean distance squared as the cost
-        cost = ((b - transformed) ** 2).mean()
+        # Transform the original input array, including zero-weighted points
+        transformed = transform_points(a_original, matrix)
+        # The cost is the (weighted) sum of the euclidean distances between
+        # the transformed source points and the target points.
+        cost = (((b_nonzero - transformed[nonzero_weights]) ** 2) * w_norm).sum()
         return matrix, transformed, cost
     else:
         return matrix
diff --git a/trimesh/resolvers.py b/trimesh/resolvers.py
index 226ae350..02e0ba62 100644
--- a/trimesh/resolvers.py
+++ b/trimesh/resolvers.py
@@ -12,7 +12,7 @@ import itertools
 import os
 
 from . import caching, util
-from .typed import Dict, Mapping, Optional, Union
+from .typed import Optional, Union
 
 # URL parsing for remote resources via WebResolver
 try:
@@ -37,24 +37,20 @@ class Resolver(util.ABC):
         raise NotImplementedError()
 
     @abc.abstractmethod
-    def write(self, name: str, data):
+    def write(self, name, data):
         raise NotImplementedError("`write` not implemented!")
 
     @abc.abstractmethod
-    def namespaced(self, namespace: str):
+    def namespaced(self, namespace):
         raise NotImplementedError("`namespaced` not implemented!")
 
-    @abc.abstractmethod
-    def keys(self):
-        raise NotImplementedError("`keys` not implemented!")
-
-    def __getitem__(self, key: str):
+    def __getitem__(self, key):
         return self.get(key)
 
-    def __setitem__(self, key: str, value):
+    def __setitem__(self, key, value):
         return self.write(key, value)
 
-    def __contains__(self, key: str) -> bool:
+    def __contains__(self, key):
         return key in self.keys()
 
 
@@ -167,7 +163,7 @@ class ZipResolver(Resolver):
     Resolve files inside a ZIP archive.
     """
 
-    def __init__(self, archive: Optional[Dict] = None, namespace: Optional[str] = None):
+    def __init__(self, archive=None, namespace=None):
         """
         Resolve files inside a ZIP archive as loaded by
         trimesh.util.decompress
@@ -209,7 +205,7 @@ class ZipResolver(Resolver):
             ]
         return self.archive.keys()
 
-    def write(self, key: str, value) -> None:
+    def write(self, key: str, value):
         """
         Store a value in the current archive.
 
@@ -224,7 +220,7 @@ class ZipResolver(Resolver):
             self.archive = {}
         self.archive[key] = value
 
-    def get(self, name: str) -> bytes:
+    def get(self, name: str):
         """
         Get an asset from the ZIP archive.
 
@@ -306,7 +302,7 @@ class WebResolver(Resolver):
     Resolve assets from a remote URL.
     """
 
-    def __init__(self, url: str):
+    def __init__(self, url):
         """
         Resolve assets from a base URL.
 
@@ -321,6 +317,7 @@ class WebResolver(Resolver):
 
         # parse string into namedtuple
         parsed = urlparse(url)
+
         # we want a base url
         split = [i for i in parsed.path.split("/") if len(i) > 0]
 
@@ -334,11 +331,6 @@ class WebResolver(Resolver):
         else:
             # recombine into string ignoring any double slashes
             path = "/".join(split)
-
-        # save the URL we were created with, i.e.
-        # `https://stuff.com/models/thing.glb`
-        self.url = url
-        # save the root url, i.e. `https://stuff.com/models`
         self.base_url = (
             "/".join(
                 i
@@ -353,7 +345,7 @@ class WebResolver(Resolver):
         # we should always have ended with a single slash
         assert self.base_url.endswith("/")
 
-    def get(self, name: str) -> bytes:
+    def get(self, name):
         """
         Get a resource from the remote site.
 
@@ -386,25 +378,7 @@ class WebResolver(Resolver):
         # return the bytes of the response
         return response.content
 
-    def get_base(self) -> bytes:
-        """
-        Fetch the data at the full URL this resolver was
-        instantiated with, i.e. `https://stuff.com/hi.glb`
-        this will return the response.
-
-        Returns
-        --------
-        content
-          The value at `self.url`
-        """
-        import httpx
-
-        # just fetch the url we were created with
-        response = httpx.get(self.url, follow_redirects=True)
-        response.raise_for_status()
-        return response.content
-
-    def namespaced(self, namespace: str) -> "WebResolver":
+    def namespaced(self, namespace):
         """
         Return a namespaced version of current resolver.
 
@@ -422,10 +396,7 @@ class WebResolver(Resolver):
         return WebResolver(url=self.base_url + namespace)
 
     def write(self, key, value):
-        raise NotImplementedError("`WebResolver` is read-only!")
-
-    def keys(self):
-        raise NotImplementedError("`WebResolver` can't list keys")
+        raise NotImplementedError("can't write to remote")
 
 
 class GithubResolver(Resolver):
@@ -484,6 +455,7 @@ class GithubResolver(Resolver):
     @property
     def zipped(self) -> ZipResolver:
         """
+
         - opened zip file
         - locally saved zip file
         - retrieve zip file and saved
@@ -597,7 +569,3 @@ def nearby_names(name, namespace=None):
         strip = namespace.strip("/").split("/")[: -name.count("..")]
         strip.extend(name.split("..")[-1].strip("/").split("/"))
         yield "/".join(strip)
-
-
-# most loaders can use a mapping in additon to a resolver
-ResolverLike = Union[Resolver, Mapping]
diff --git a/trimesh/resources/__init__.py b/trimesh/resources/__init__.py
index 77b48a38..f0c66241 100644
--- a/trimesh/resources/__init__.py
+++ b/trimesh/resources/__init__.py
@@ -1,15 +1,81 @@
 import json
 import os
-from io import BytesIO
+import warnings
 
-from ..typed import Dict
+from ..typed import Dict, Stream
+from ..util import decode_text, wrap_as_stream
 
 # find the current absolute path to this directory
 _pwd = os.path.expanduser(os.path.abspath(os.path.dirname(__file__)))
+
 # once resources are loaded cache them
 _cache = {}
 
 
+def get(
+    name: str, decode: bool = True, decode_json: bool = False, as_stream: bool = False
+):
+    """
+    DERECATED JANUARY 2025 REPLACE WITH TYPED `get_json`, `get_string`, etc.
+    """
+    warnings.warn(
+        "`trimesh.resources.get` is deprecated "
+        + "and will be removed in January 2025: "
+        + "replace with typed `trimesh.resources.get_*type*`",
+        category=DeprecationWarning,
+        stacklevel=2,
+    )
+    return _get(name=name, decode=decode, decode_json=decode_json, as_stream=as_stream)
+
+
+def _get(name: str, decode: bool, decode_json: bool, as_stream: bool):
+    """
+    Get a resource from the `trimesh/resources` folder.
+
+    Parameters
+    -------------
+    name : str
+      File path relative to `trimesh/resources`
+    decode : bool
+      Whether or not to decode result as UTF-8
+    decode_json : bool
+      Run `json.loads` on resource if True.
+    as_stream : bool
+      Return as a file-like object
+
+    Returns
+    -------------
+    resource : str, bytes, or decoded JSON
+      File data
+    """
+    # key by name and decode
+    cache_key = (name, bool(decode), bool(decode_json), bool(as_stream))
+    cached = _cache.get(cache_key)
+    if hasattr(cached, "seek"):
+        cached.seek(0)
+    if cached is not None:
+        return cached
+
+    # get the resource using relative names
+    with open(os.path.join(_pwd, name), "rb") as f:
+        resource = f.read()
+
+    # make sure we return it as a string if asked
+    if decode:
+        # will decode into text if possibly
+        resource = decode_text(resource)
+
+    if decode_json:
+        resource = json.loads(resource)
+    elif as_stream:
+        resource = wrap_as_stream(resource)
+
+    # store for later access
+    _cache[cache_key] = resource
+
+    return resource
+
+
 def get_schema(name: str) -> Dict:
     """
     Load a schema and evaluate the referenced files.
@@ -29,8 +95,8 @@ def get_schema(name: str) -> Dict:
 
     # get a resolver for our base path
     resolver = FilePathResolver(os.path.join(_pwd, "schema", name))
-    # recursively load `$ref` keys
-    return resolve(json.loads(resolver.get(name).decode("utf-8")), resolver=resolver)
+    # recursively load $ref keys
+    return resolve(json.loads(decode_text(resolver.get(name))), resolver=resolver)
 
 
 def get_json(name: str) -> Dict:
@@ -40,14 +106,14 @@ def get_json(name: str) -> Dict:
     Parameters
     -------------
     name : str
-      File path relative to `trimesh/resources/{name}`
+      File path relative to `trimesh/resources`
 
     Returns
     -------------
     resource
       File data decoded from JSON.
     """
-    return json.loads(get_bytes(name).decode("utf-8"))
+    return _get(name, decode=True, decode_json=True, as_stream=False)
 
 
 def get_string(name: str) -> str:
@@ -56,7 +122,7 @@ def get_string(name: str) -> str:
 
     Parameters
     -------------
-    name
+    name : str
       File path relative to `trimesh/resources`
 
     Returns
@@ -64,7 +130,7 @@ def get_string(name: str) -> str:
     resource
       File data as a string.
     """
-    return get_bytes(name).decode("utf-8")
+    return _get(name, decode=True, decode_json=False, as_stream=False)
 
 
 def get_bytes(name: str) -> bytes:
@@ -73,7 +139,7 @@ def get_bytes(name: str) -> bytes:
 
     Parameters
     -------------
-    name
+    name : str
       File path relative to `trimesh/resources`
 
     Returns
@@ -81,21 +147,10 @@ def get_bytes(name: str) -> bytes:
     resource
       File data as raw bytes.
     """
-    cached = _cache.get(name, None)
-    if cached is not None:
-        return cached
-
-    # get the resource using relative names
-    # all templates are using POSIX relative paths
-    # so fix them to be platform-specific
-    with open(os.path.join(_pwd, *name.split("/")), "rb") as f:
-        resource = f.read()
-
-    _cache[name] = resource
-    return resource
+    return _get(name, decode=False, decode_json=False, as_stream=False)
 
 
-def get_stream(name: str) -> BytesIO:
+def get_stream(name: str) -> Stream:
     """
     Get a resource from the `trimesh/resources` folder as a binary stream.
 
@@ -110,4 +165,4 @@ def get_stream(name: str) -> BytesIO:
       File data as a binary stream.
     """
 
-    return BytesIO(get_bytes(name))
+    return _get(name, decode=False, decode_json=False, as_stream=True)
diff --git a/trimesh/scene/scene.py b/trimesh/scene/scene.py
index 6797d493..2f5814b9 100644
--- a/trimesh/scene/scene.py
+++ b/trimesh/scene/scene.py
@@ -186,6 +186,10 @@ class Scene(Geometry3D):
             self.graph.transforms = concat.graph.transforms
             return
 
+        if not hasattr(geometry, "vertices"):
+            util.log.debug(f"unknown type ({type(geometry).__name__}) added to scene!")
+            return
+
         # get or create a name to reference the geometry by
         if geom_name is not None:
             # if name is passed use it
@@ -276,7 +280,7 @@ class Scene(Geometry3D):
           A number between 0.0 and 1.0 for how much
         face_count
           Target number of faces desired in the resulting mesh.
-        agression
+        aggression
           An integer between `0` and `10`, the scale being roughly
           `0` is "slow and good" and `10` being "fast and bad."
 
@@ -359,8 +363,9 @@ class Scene(Geometry3D):
         corners = {}
         # collect vertices for every mesh
         vertices = {
-            k: m.vertices if hasattr(m, "vertices") and len(m.vertices) > 0 else m.bounds
+            k: m.vertices
             for k, m in self.geometry.items()
+            if hasattr(m, "vertices") and len(m.vertices) > 0
         }
         # handle 2D geometries
         vertices.update(


Test patch:
diff --git a/tests/test_export.py b/tests/test_export.py
index ea750297..e671fc78 100644
--- a/tests/test_export.py
+++ b/tests/test_export.py
@@ -336,6 +336,8 @@ class ExportTest(g.unittest.TestCase):
         # it's wordy
         f = g.trimesh.exchange.load._parse_file_args
 
+        RET_COUNT = 5
+
         # a path that doesn't exist
         nonexists = f"/banana{g.random()}"
         assert not g.os.path.exists(nonexists)
@@ -346,11 +348,13 @@ class ExportTest(g.unittest.TestCase):
 
         # should be able to extract type from passed filename
         args = f(file_obj=exists, file_type=None)
-        assert args.file_type == "obj"
+        assert len(args) == RET_COUNT
+        assert args[1] == "obj"
 
         # should be able to extract correct type from longer name
         args = f(file_obj=exists, file_type="YOYOMA.oBj")
-        assert args.file_type == "obj"
+        assert len(args) == RET_COUNT
+        assert args[1] == "obj"
 
         # with a nonexistent file and no extension it should raise
         try:
@@ -363,13 +367,15 @@ class ExportTest(g.unittest.TestCase):
         # nonexistent file with extension passed should return
         # file name anyway, maybe something else can handle it
         args = f(file_obj=nonexists, file_type=".ObJ")
+        assert len(args) == RET_COUNT
         # should have cleaned up case
-        assert args.file_type == "obj"
+        assert args[1] == "obj"
 
         # make sure overriding type works for string filenames
         args = f(file_obj=exists, file_type="STL")
+        assert len(args) == RET_COUNT
         # should have used manually passed type over .obj
-        assert args.file_type == "stl"
+        assert args[1] == "stl"
 
     def test_buffered_random(self):
         """Test writing to non-standard file"""
diff --git a/tests/test_gltf.py b/tests/test_gltf.py
index c2c78c8e..c43a5e9e 100644
--- a/tests/test_gltf.py
+++ b/tests/test_gltf.py
@@ -53,9 +53,6 @@ def validate_glb(data, name=None):
         raise ValueError("gltf_validator failed")
 
 
-load_kwargs = g.trimesh.exchange.load._load_kwargs
-
-
 class GLTFTest(g.unittest.TestCase):
     def test_duck(self):
         scene = g.get_mesh("Duck.glb", process=False)
@@ -199,7 +196,7 @@ class GLTFTest(g.unittest.TestCase):
 
         kwargs = g.trimesh.exchange.gltf.load_glb(g.trimesh.util.wrap_as_stream(export))
         # roundtrip it
-        reloaded = load_kwargs(kwargs)
+        reloaded = g.trimesh.exchange.load.load_kwargs(kwargs)
         # make basic assertions
         g.scene_equal(original, reloaded)
 
@@ -267,7 +264,7 @@ class GLTFTest(g.unittest.TestCase):
         assert len(export.keys()) == 2
 
         # reload the export
-        reloaded = load_kwargs(
+        reloaded = g.trimesh.exchange.load.load_kwargs(
             g.trimesh.exchange.gltf.load_gltf(
                 file_obj=None, resolver=g.trimesh.visual.resolvers.ZipResolver(export)
             )
diff --git a/tests/test_html.py b/tests/test_html.py
index a56bb544..135ef9b3 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -24,9 +24,6 @@ class ViewTest(g.unittest.TestCase):
         children = list(h.body.iterchildren())
         assert len(children) >= 2
 
-        # make sure this is returning anything
-        assert js.scene_to_notebook(s) is not None
-
     def test_inNB(self):
         import trimesh.viewer.notebook as js
 
diff --git a/tests/test_loaded.py b/tests/test_loaded.py
index 09105545..92b8a10c 100644
--- a/tests/test_loaded.py
+++ b/tests/test_loaded.py
@@ -11,9 +11,8 @@ class LoaderTest(g.unittest.TestCase):
         """
         # get a unit cube from localhost
         with g.serve_meshes() as address:
-            scene = g.trimesh.exchange.load.load_remote(url=address + "/unit_cube.STL")
+            mesh = g.trimesh.exchange.load.load_remote(url=address + "/unit_cube.STL")
 
-        mesh = scene.to_mesh()
         assert g.np.isclose(mesh.volume, 1.0)
         assert isinstance(mesh, g.trimesh.Trimesh)
 
@@ -36,7 +35,7 @@ class LoaderTest(g.unittest.TestCase):
         # check load_mesh
         file_obj = open(g.os.path.join(g.dir_models, "featuretype.STL"), "rb")
         assert not file_obj.closed
-        mesh = g.trimesh.load_mesh(file_obj=file_obj, file_type="stl")
+        mesh = g.trimesh.load(file_obj=file_obj, file_type="stl")
         # should have actually loaded the mesh
         assert len(mesh.faces) == 3476
         # should not close the file object
diff --git a/tests/test_obj.py b/tests/test_obj.py
index e5480675..c241e436 100644
--- a/tests/test_obj.py
+++ b/tests/test_obj.py
@@ -28,18 +28,6 @@ class OBJTest(g.unittest.TestCase):
         rec = g.roundtrip(m.export(file_type="obj"), file_type="obj")
         assert g.np.isclose(m.area, rec.area)
 
-    def test_keep_unreferenced(self):
-        # try loading a short mesh with 2 vertices, one of which is referenced
-        m = g.trimesh.load(
-            g.trimesh.util.wrap_as_stream("o mesh\nv 1 1 1\nv 1 1 2\nf 1 1 1"),
-            file_type="obj",
-            process=False,
-            maintain_order=True,
-        )
-
-        assert g.np.allclose(m.faces[0], [0, 0, 0])
-        assert g.np.allclose(m.vertices, [[1, 1, 1], [1, 1, 2]])
-
     def test_trailing(self):
         # test files with texture and trailing slashes
         m = g.get_mesh("jacked.obj")
@@ -347,9 +335,6 @@ class OBJTest(g.unittest.TestCase):
 
     def test_scene_export_material_name(self):
         s = g.get_mesh("fuze.obj", force="scene")
-
-        g.log.warning(s.geometry)
-
         dummy = "fuxx"
         s.geometry["fuze.obj"].visual.material.name = dummy
 
diff --git a/tests/test_registration.py b/tests/test_registration.py
index 44f422cf..75bea97c 100644
--- a/tests/test_registration.py
+++ b/tests/test_registration.py
@@ -60,7 +60,8 @@ class RegistrationTest(g.unittest.TestCase):
             )
             if weight:
                 # weights should have changed the matrix
-                assert not g.np.allclose(matrixN, matrixN_C)
+                # todo : check something less silly here?
+                assert g.np.allclose(matrixN, matrixN_C)
             else:
                 # no weights so everything should be identical
                 assert g.np.allclose(matrixN, matrixN_C)
diff --git a/tests/test_scenegraph.py b/tests/test_scenegraph.py
index 313324ff..b08a4c31 100644
--- a/tests/test_scenegraph.py
+++ b/tests/test_scenegraph.py
@@ -10,325 +10,340 @@ def random_chr():
     return chr(ord("a") + int(round(g.random() * 25)))
 
 
-class GraphTests(g.unittest.TestCase):
-    def test_forest(self):
-        graph = EnforcedForest()
-        for _i in range(5000):
-            graph.add_edge(random_chr(), random_chr())
-
-    def test_cache(self):
-        for _i in range(10):
-            scene = g.trimesh.Scene()
-            scene.add_geometry(g.trimesh.creation.box())
-
-            mod = [scene.graph.__hash__()]
-            scene.set_camera()
-            mod.append(scene.graph.__hash__())
-            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.__hash__())
-            assert mod[-1] != mod[-2]
-
-            assert g.np.allclose(scene.camera_transform, g.np.eye(4))
-            assert mod[-1] != mod[-2]
-
-    def test_successors(self):
-        s = g.get_mesh("CesiumMilkTruck.glb")
-        assert len(s.graph.nodes_geometry) == 5
-
-        # world should be root frame
-        assert s.graph.transforms.successors(s.graph.base_frame) == set(s.graph.nodes)
-
-        for n in s.graph.nodes:
-            # successors should always return subset of nodes
-            succ = s.graph.transforms.successors(n)
-            assert succ.issubset(s.graph.nodes)
-            # we self-include node in successors
-            assert n in succ
-
-        # test getting a subscene from successors
-        ss = s.subscene("3")
-        assert len(ss.geometry) == 1
-        assert len(ss.graph.nodes_geometry) == 1
-
-        assert isinstance(s.graph.to_networkx(), g.nx.DiGraph)
-
-    def test_nodes(self):
-        # get a scene graph
-        graph = g.get_mesh("cycloidal.3DXML").graph
-        # get any non-root node
-        node = next(iter(set(graph.nodes).difference([graph.base_frame])))
-        # remove that node
-        graph.transforms.remove_node(node)
-        # should have dumped the cache and removed the node
-        assert node not in graph.nodes
-
-    def test_remove_geometries(self):
-        # remove geometries from a scene graph
-        scene = g.get_mesh("cycloidal.3DXML")
-
-        # only keep geometry instances of these
-        keep = {"disc_cam_A", "disc_cam_B", "vxb-6800-2rs"}
-
-        assert len(scene.duplicate_nodes) == 12
-
-        # should remove instance references except `keep`
-        scene.graph.remove_geometries(set(scene.geometry.keys()).difference(keep))
-
-        # there should now be three groups of duplicate nodes
-        assert len(scene.duplicate_nodes) == len(keep)
-
-    def test_kwargs(self):
-        # test the function that converts various
-        # arguments into a homogeneous transformation
-        f = g.trimesh.scene.transforms.kwargs_to_matrix
-        # no arguments should be an identity matrix
-        assert g.np.allclose(f(), g.np.eye(4))
-
-        # a passed matrix should return immediately
-        fix = g.random((4, 4))
-        assert g.np.allclose(f(matrix=fix), fix)
-
-        quat = g.trimesh.unitize([1, 2, 3, 1])
-        trans = [1.0, 2.0, 3.0]
-        rot = g.trimesh.transformations.quaternion_matrix(quat)
-        # should be the same as passed to transformations
-        assert g.np.allclose(rot, f(quaternion=quat))
-
-        # try passing both quaternion and translation
-        combine = f(quaternion=quat, translation=trans)
-        # should be the same as passed and computed
-        assert g.np.allclose(combine[:3, :3], rot[:3, :3])
-        assert g.np.allclose(combine[:3, 3], trans)
-
-    def test_remove_node(self):
-        s = g.get_mesh("CesiumMilkTruck.glb")
-
-        assert len(s.graph.nodes_geometry) == 5
-        assert len(s.graph.nodes) == 9
-        assert len(s.graph.transforms.node_data) == 9
-        assert len(s.graph.transforms.edge_data) == 8
-        assert len(s.graph.transforms.parents) == 8
-
-        assert s.graph.transforms.remove_node("1")
-
-        assert len(s.graph.nodes_geometry) == 5
-        assert len(s.graph.nodes) == 8
-        assert len(s.graph.transforms.node_data) == 8
-        assert len(s.graph.transforms.edge_data) == 6
-        assert len(s.graph.transforms.parents) == 6
-
-    def test_subscene(self):
-        s = g.get_mesh("CesiumMilkTruck.glb")
-
-        assert len(s.graph.nodes) == 9
-        assert len(s.graph.transforms.node_data) == 9
-        assert len(s.graph.transforms.edge_data) == 8
-
-        ss = s.subscene("3")
-
-        assert ss.graph.base_frame == "3"
-        assert set(ss.graph.nodes) == {"3", "4"}
-        assert len(ss.graph.transforms.node_data) == 2
-        assert len(ss.graph.transforms.edge_data) == 1
-        assert list(ss.graph.transforms.edge_data.keys()) == [("3", "4")]
-
-    def test_scene_transform(self):
-        # get a scene graph
-        scene = g.get_mesh("cycloidal.3DXML")
-
-        # copy the original bounds of the scene's convex hull
-        b = scene.convex_hull.bounds.tolist()
-        # dump it into a single mesh
-        m = scene.to_mesh()
-
-        # mesh bounds should match exactly
-        assert g.np.allclose(m.bounds, b)
-        assert g.np.allclose(scene.convex_hull.bounds, b)
-
-        # get a random rotation matrix
-        T = g.trimesh.transformations.random_rotation_matrix()
-
-        # apply it to both the mesh and the scene
-        m.apply_transform(T)
-        scene.apply_transform(T)
-
-        # the mesh and scene should have the same bounds
-        assert g.np.allclose(m.convex_hull.bounds, scene.convex_hull.bounds)
-        # should have moved from original position
-        assert not g.np.allclose(m.convex_hull.bounds, b)
-
-    def test_simplify(self):
-        if not g.trimesh.util.has_module("fast_simplification"):
-            return
-
-        # get a scene graph
-        scene: g.trimesh.Scene = g.get_mesh("cycloidal.3DXML")
-
-        original = scene.to_mesh()
-
-        scene.simplify_quadric_decimation(percent=0.0, aggression=0)
-        assert len(scene.to_mesh().vertices) < len(original.vertices)
-
-    def test_reverse(self):
-        tf = g.trimesh.transformations
-
-        s = g.trimesh.scene.Scene()
-        s.add_geometry(
-            g.trimesh.creation.box(),
-            parent_node_name="world",
-            node_name="foo",
-            transform=tf.translation_matrix([0, 0, 1]),
-        )
-
-        s.add_geometry(
-            g.trimesh.creation.box(),
-            parent_node_name="foo",
-            node_name="foo2",
-            transform=tf.translation_matrix([0, 0, 1]),
-        )
-
-        assert len(s.graph.transforms.edge_data) == 2
-        a = s.graph.get(frame_from="world", frame_to="foo2")
-
-        assert len(s.graph.transforms.edge_data) == 2
-
-        # try going backward
-        i = s.graph.get(frame_from="foo2", frame_to="world")
-        # matrix should be inverted if you're going the other way
-        assert g.np.allclose(a[0], g.np.linalg.inv(i[0]))
-
-        # try getting foo2 with shorthand
-        b = s.graph.get(frame_to="foo2")
-        c = s.graph["foo2"]
-        # matrix should be inverted if you're going the other way
-        assert g.np.allclose(a[0], c[0])
-        assert g.np.allclose(b[0], c[0])
-
-        # get should not have edited edge data
-        assert len(s.graph.transforms.edge_data) == 2
-
-    def test_shortest_path(self):
-        # compare the EnforcedForest shortest path algo
-        # to the more general networkx.shortest_path algo
-        if g.sys.version_info < (3, 7):
-            # old networkx is a lot different
-            return
-
-        tf = g.trimesh.transformations
-        # start with a known good random tree
-        edges = [tuple(row) for row in g.data["random_tree"]]
-        tree = g.nx.from_edgelist(edges, create_using=g.nx.DiGraph)
-
-        r_choices = g.random((len(edges), 2))
-        r_matrices = g.random_transforms(len(edges))
-        edgelist = {}
-        for e, r_choice, r_mat in zip(edges, r_choices, r_matrices):
-            data = {}
-            if r_choice[0] > 0.5:
-                # if a matrix is omitted but an edge exists it is
-                # the same as passing an identity matrix
-                data["matrix"] = r_mat
-            if r_choice[1] > 0.4:
-                # a geometry is not required for a node
-                data["geometry"] = str(int(r_choice[1] * 1e8))
-            edgelist[e] = data
-
-        # now apply the random data to an EnforcedForest
-        forest = g.trimesh.scene.transforms.EnforcedForest()
-        for k, v in edgelist.items():
-            forest.add_edge(*k, **v)
-
-        # generate a lot of random queries
-        queries = g.np.random.choice(list(forest.nodes), 10000).reshape((-1, 2))
-        # filter out any self-queries as networkx doesn't handle them
-        queries = queries[g.np.ptp(queries, axis=1) > 0]
-
-        # now run our shortest path algorithm in a profiler
-        with g.Profiler() as P:
-            ours = [forest.shortest_path(*q) for q in queries]
-        # print this way to avoid a python2 syntax error
-        g.log.debug(P.output_text())
-
-        # check truth from networkx with an undirected graph
-        undir = tree.to_undirected()
-        with g.Profiler() as P:
-            truth = [g.nx.shortest_path(undir, *q) for q in queries]
-        g.log.debug(P.output_text())
-
-        # now compare our shortest path with networkx
-        for a, b, q in zip(truth, ours, queries):
-            if tuple(a) != tuple(b):
-                # raise the query that killed us
-                raise ValueError(q)
-
-        # now try creating this as a full scenegraph
-        sg = g.trimesh.scene.transforms.SceneGraph()
-        [
-            sg.update(frame_from=k[0], frame_to=k[1], **kwargs)
-            for k, kwargs in edgelist.items()
-        ]
-
-        with g.Profiler() as P:
-            matgeom = [sg.get(frame_from=q[0], frame_to=q[1]) for q in queries]
-        g.log.debug(P.output_text())
-
-        # all of the matrices should be rigid transforms
-        assert all(tf.is_rigid(mat) for mat, _ in matgeom)
-
-    def test_scaling_order(self):
-        s = g.trimesh.creation.box().scene()
-        scaling = 1.0 / 3.0
-        c = s.scaled(scaling)
-        factor = c.geometry["geometry_0"].vertices / s.geometry["geometry_0"].vertices
-        assert g.np.allclose(factor, scaling)
-        # should be returning itself
-        r = s.apply_translation([10.5, 10.5, 10.5])
-        assert g.np.allclose(r.bounds, [[10, 10, 10], [11, 11, 11]])
-        assert g.np.allclose(s.bounds, [[10, 10, 10], [11, 11, 11]])
-
-    def test_translation_cache(self):
-        # scene with non-geometry nodes
-        c = g.get_mesh("cycloidal.3DXML")
-        s = c.scaled(1.0 / c.extents)
-        # get the pre-translation bounds
-        ori = s.bounds.copy()
-        # apply a translation
-        s.apply_translation([10, 10, 10])
-        assert g.np.allclose(s.bounds, ori + 10)
-
-    def test_translation_origin(self):
-        # check to see if we can translate to the origin
-        c = g.get_mesh("cycloidal.3DXML")
-        c.apply_transform(g.trimesh.transformations.random_rotation_matrix())
-        s = c.scaled(1.0 / c.extents)
-        # shouldn't be at the origin
-        assert not g.np.allclose(s.bounds[0], 0.0)
-        # should move to the origin
-        s.apply_translation(-s.bounds[0])
-        assert g.np.allclose(s.bounds[0], 0)
-
-    def test_reconstruct(self):
-        original = g.get_mesh("cycloidal.3DXML")
-        assert isinstance(original, g.trimesh.Scene)
-
-        # get the scene as "baked" meshes with no scene graph
-        dupe = g.trimesh.Scene(original.dump())
-        assert len(dupe.geometry) > len(original.geometry)
-
-        with g.Profiler() as P:
-            # reconstruct the instancing using `duplicate_nodes` and `procrustes`
-            rec = dupe.reconstruct_instances()
-        g.log.info(P.output_text())
-
-        assert len(rec.graph.nodes_geometry) == len(original.graph.nodes_geometry)
-        assert len(rec.geometry) == len(original.geometry)
-        assert g.np.allclose(rec.extents, original.extents, rtol=1e-8)
-        assert g.np.allclose(rec.center_mass, original.center_mass, rtol=1e-8)
+def test_forest():
+    graph = EnforcedForest()
+    for _i in range(5000):
+        graph.add_edge(random_chr(), random_chr())
+
+
+def test_cache():
+    for _i in range(10):
+        scene = g.trimesh.Scene()
+        scene.add_geometry(g.trimesh.creation.box())
+
+        mod = [scene.graph.__hash__()]
+        scene.set_camera()
+        mod.append(scene.graph.__hash__())
+        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.__hash__())
+        assert mod[-1] != mod[-2]
+
+        assert g.np.allclose(scene.camera_transform, g.np.eye(4))
+        assert mod[-1] != mod[-2]
+
+
+def test_successors():
+    s = g.get_mesh("CesiumMilkTruck.glb")
+    assert len(s.graph.nodes_geometry) == 5
+
+    # world should be root frame
+    assert s.graph.transforms.successors(s.graph.base_frame) == set(s.graph.nodes)
+
+    for n in s.graph.nodes:
+        # successors should always return subset of nodes
+        succ = s.graph.transforms.successors(n)
+        assert succ.issubset(s.graph.nodes)
+        # we self-include node in successors
+        assert n in succ
+
+    # test getting a subscene from successors
+    ss = s.subscene("3")
+    assert len(ss.geometry) == 1
+    assert len(ss.graph.nodes_geometry) == 1
+
+    assert isinstance(s.graph.to_networkx(), g.nx.DiGraph)
+
+
+def test_nodes():
+    # get a scene graph
+    graph = g.get_mesh("cycloidal.3DXML").graph
+    # get any non-root node
+    node = next(iter(set(graph.nodes).difference([graph.base_frame])))
+    # remove that node
+    graph.transforms.remove_node(node)
+    # should have dumped the cache and removed the node
+    assert node not in graph.nodes
+
+
+def test_remove_geometries():
+    # remove geometries from a scene graph
+    scene = g.get_mesh("cycloidal.3DXML")
+
+    # only keep geometry instances of these
+    keep = {"disc_cam_A", "disc_cam_B", "vxb-6800-2rs"}
+
+    assert len(scene.duplicate_nodes) == 12
+
+    # should remove instance references except `keep`
+    scene.graph.remove_geometries(set(scene.geometry.keys()).difference(keep))
+
+    # there should now be three groups of duplicate nodes
+    assert len(scene.duplicate_nodes) == len(keep)
+
+
+def test_kwargs():
+    # test the function that converts various
+    # arguments into a homogeneous transformation
+    f = g.trimesh.scene.transforms.kwargs_to_matrix
+    # no arguments should be an identity matrix
+    assert g.np.allclose(f(), g.np.eye(4))
+
+    # a passed matrix should return immediately
+    fix = g.random((4, 4))
+    assert g.np.allclose(f(matrix=fix), fix)
+
+    quat = g.trimesh.unitize([1, 2, 3, 1])
+    trans = [1.0, 2.0, 3.0]
+    rot = g.trimesh.transformations.quaternion_matrix(quat)
+    # should be the same as passed to transformations
+    assert g.np.allclose(rot, f(quaternion=quat))
+
+    # try passing both quaternion and translation
+    combine = f(quaternion=quat, translation=trans)
+    # should be the same as passed and computed
+    assert g.np.allclose(combine[:3, :3], rot[:3, :3])
+    assert g.np.allclose(combine[:3, 3], trans)
+
+
+def test_remove_node():
+    s = g.get_mesh("CesiumMilkTruck.glb")
+
+    assert len(s.graph.nodes_geometry) == 5
+    assert len(s.graph.nodes) == 9
+    assert len(s.graph.transforms.node_data) == 9
+    assert len(s.graph.transforms.edge_data) == 8
+    assert len(s.graph.transforms.parents) == 8
+
+    assert s.graph.transforms.remove_node("1")
+
+    assert len(s.graph.nodes_geometry) == 5
+    assert len(s.graph.nodes) == 8
+    assert len(s.graph.transforms.node_data) == 8
+    assert len(s.graph.transforms.edge_data) == 6
+    assert len(s.graph.transforms.parents) == 6
+
+
+def test_subscene():
+    s = g.get_mesh("CesiumMilkTruck.glb")
+
+    assert len(s.graph.nodes) == 9
+    assert len(s.graph.transforms.node_data) == 9
+    assert len(s.graph.transforms.edge_data) == 8
+
+    ss = s.subscene("3")
+
+    assert ss.graph.base_frame == "3"
+    assert set(ss.graph.nodes) == {"3", "4"}
+    assert len(ss.graph.transforms.node_data) == 2
+    assert len(ss.graph.transforms.edge_data) == 1
+    assert list(ss.graph.transforms.edge_data.keys()) == [("3", "4")]
+
+
+def test_scene_transform():
+    # get a scene graph
+    scene = g.get_mesh("cycloidal.3DXML")
+
+    # copy the original bounds of the scene's convex hull
+    b = scene.convex_hull.bounds.tolist()
+    # dump it into a single mesh
+    m = scene.to_mesh()
+
+    # mesh bounds should match exactly
+    assert g.np.allclose(m.bounds, b)
+    assert g.np.allclose(scene.convex_hull.bounds, b)
+
+    # get a random rotation matrix
+    T = g.trimesh.transformations.random_rotation_matrix()
+
+    # apply it to both the mesh and the scene
+    m.apply_transform(T)
+    scene.apply_transform(T)
+
+    # the mesh and scene should have the same bounds
+    assert g.np.allclose(m.convex_hull.bounds, scene.convex_hull.bounds)
+    # should have moved from original position
+    assert not g.np.allclose(m.convex_hull.bounds, b)
+
+
+def test_simplify():
+    if not g.trimesh.util.has_module("fast_simplification"):
+        return
+
+    # get a scene graph
+    scene: g.trimesh.Scene = g.get_mesh("cycloidal.3DXML")
+
+    original = scene.to_mesh()
+
+    scene.simplify_quadric_decimation(percent=0.0, aggression=0)
+    assert len(scene.to_mesh().vertices) < len(original.vertices)
+
+
+def test_reverse():
+    tf = g.trimesh.transformations
+
+    s = g.trimesh.scene.Scene()
+    s.add_geometry(
+        g.trimesh.creation.box(),
+        parent_node_name="world",
+        node_name="foo",
+        transform=tf.translation_matrix([0, 0, 1]),
+    )
+
+    s.add_geometry(
+        g.trimesh.creation.box(),
+        parent_node_name="foo",
+        node_name="foo2",
+        transform=tf.translation_matrix([0, 0, 1]),
+    )
+
+    assert len(s.graph.transforms.edge_data) == 2
+    a = s.graph.get(frame_from="world", frame_to="foo2")
+
+    assert len(s.graph.transforms.edge_data) == 2
+
+    # try going backward
+    i = s.graph.get(frame_from="foo2", frame_to="world")
+    # matrix should be inverted if you're going the other way
+    assert g.np.allclose(a[0], g.np.linalg.inv(i[0]))
+
+    # try getting foo2 with shorthand
+    b = s.graph.get(frame_to="foo2")
+    c = s.graph["foo2"]
+    # matrix should be inverted if you're going the other way
+    assert g.np.allclose(a[0], c[0])
+    assert g.np.allclose(b[0], c[0])
+
+    # get should not have edited edge data
+    assert len(s.graph.transforms.edge_data) == 2
+
+
+def test_shortest_path():
+    # compare the EnforcedForest shortest path algo
+    # to the more general networkx.shortest_path algo
+    if g.sys.version_info < (3, 7):
+        # old networkx is a lot different
+        return
+
+    tf = g.trimesh.transformations
+    # start with a known good random tree
+    edges = [tuple(row) for row in g.data["random_tree"]]
+    tree = g.nx.from_edgelist(edges, create_using=g.nx.DiGraph)
+
+    r_choices = g.random((len(edges), 2))
+    r_matrices = g.random_transforms(len(edges))
+    edgelist = {}
+    for e, r_choice, r_mat in zip(edges, r_choices, r_matrices):
+        data = {}
+        if r_choice[0] > 0.5:
+            # if a matrix is omitted but an edge exists it is
+            # the same as passing an identity matrix
+            data["matrix"] = r_mat
+        if r_choice[1] > 0.4:
+            # a geometry is not required for a node
+            data["geometry"] = str(int(r_choice[1] * 1e8))
+        edgelist[e] = data
+
+    # now apply the random data to an EnforcedForest
+    forest = g.trimesh.scene.transforms.EnforcedForest()
+    for k, v in edgelist.items():
+        forest.add_edge(*k, **v)
+
+    # generate a lot of random queries
+    queries = g.np.random.choice(list(forest.nodes), 10000).reshape((-1, 2))
+    # filter out any self-queries as networkx doesn't handle them
+    queries = queries[g.np.ptp(queries, axis=1) > 0]
+
+    # now run our shortest path algorithm in a profiler
+    with g.Profiler() as P:
+        ours = [forest.shortest_path(*q) for q in queries]
+    # print this way to avoid a python2 syntax error
+    g.log.debug(P.output_text())
+
+    # check truth from networkx with an undirected graph
+    undir = tree.to_undirected()
+    with g.Profiler() as P:
+        truth = [g.nx.shortest_path(undir, *q) for q in queries]
+    g.log.debug(P.output_text())
+
+    # now compare our shortest path with networkx
+    for a, b, q in zip(truth, ours, queries):
+        if tuple(a) != tuple(b):
+            # raise the query that killed us
+            raise ValueError(q)
+
+    # now try creating this as a full scenegraph
+    sg = g.trimesh.scene.transforms.SceneGraph()
+    [
+        sg.update(frame_from=k[0], frame_to=k[1], **kwargs)
+        for k, kwargs in edgelist.items()
+    ]
+
+    with g.Profiler() as P:
+        matgeom = [sg.get(frame_from=q[0], frame_to=q[1]) for q in queries]
+    g.log.debug(P.output_text())
+
+    # all of the matrices should be rigid transforms
+    assert all(tf.is_rigid(mat) for mat, _ in matgeom)
+
+
+def test_scaling_order():
+    s = g.trimesh.creation.box().scene()
+    scaling = 1.0 / 3.0
+    c = s.scaled(scaling)
+    factor = c.geometry["geometry_0"].vertices / s.geometry["geometry_0"].vertices
+    assert g.np.allclose(factor, scaling)
+    # should be returning itself
+    r = s.apply_translation([10.5, 10.5, 10.5])
+    assert g.np.allclose(r.bounds, [[10, 10, 10], [11, 11, 11]])
+    assert g.np.allclose(s.bounds, [[10, 10, 10], [11, 11, 11]])
+
+
+def test_translation_cache():
+    # scene with non-geometry nodes
+    c = g.get_mesh("cycloidal.3DXML")
+    s = c.scaled(1.0 / c.extents)
+    # get the pre-translation bounds
+    ori = s.bounds.copy()
+    # apply a translation
+    s.apply_translation([10, 10, 10])
+    assert g.np.allclose(s.bounds, ori + 10)
+
+
+def test_translation_origin():
+    # check to see if we can translate to the origin
+    c = g.get_mesh("cycloidal.3DXML")
+    c.apply_transform(g.trimesh.transformations.random_rotation_matrix())
+    s = c.scaled(1.0 / c.extents)
+    # shouldn't be at the origin
+    assert not g.np.allclose(s.bounds[0], 0.0)
+    # should move to the origin
+    s.apply_translation(-s.bounds[0])
+    assert g.np.allclose(s.bounds[0], 0)
+
+
+def test_reconstruct():
+    original = g.get_mesh("cycloidal.3DXML")
+    assert isinstance(original, g.trimesh.Scene)
+
+    # get the scene as "baked" meshes with no scene graph
+    dupe = g.trimesh.Scene(original.dump())
+    assert len(dupe.geometry) > len(original.geometry)
+
+    with g.Profiler() as P:
+        # reconstruct the instancing using `duplicate_nodes` and `procrustes`
+        rec = dupe.reconstruct_instances()
+    g.log.info(P.output_text())
+
+    assert len(rec.graph.nodes_geometry) == len(original.graph.nodes_geometry)
+    assert len(rec.geometry) == len(original.geometry)
+    assert g.np.allclose(rec.extents, original.extents, rtol=1e-8)
+    assert g.np.allclose(rec.center_mass, original.center_mass, rtol=1e-8)
 
 
 if __name__ == "__main__":
     g.trimesh.util.attach_to_log()
-    g.unittest.main()
+
+    test_reconstruct()
diff --git a/tests/test_svg.py b/tests/test_svg.py
index 8cebf8ab..04127806 100644
--- a/tests/test_svg.py
+++ b/tests/test_svg.py
@@ -126,7 +126,7 @@ class ExportTest(g.unittest.TestCase):
                 assert g.np.isclose(a.area, b.area)
                 assert a.body_count == b.body_count
 
-            # assert r.metadata["file_path"].endswith(fn[3:])
+            assert r.metadata["file_path"].endswith(fn[3:])
 
 
 if __name__ == "__main__":
diff --git a/tests/test_voxel.py b/tests/test_voxel.py
index 341ecf62..f5d82c98 100644
--- a/tests/test_voxel.py
+++ b/tests/test_voxel.py
@@ -10,17 +10,13 @@ class VoxelGridTest(g.unittest.TestCase):
         Test that voxels work at all
         """
         for m in [
-            g.get_mesh("featuretype.STL", force="mesh"),
+            g.get_mesh("featuretype.STL"),
             g.trimesh.primitives.Box(),
             g.trimesh.primitives.Sphere(),
         ]:
             for pitch in [0.1, 0.1 - g.tol.merge]:
                 surface = m.voxelized(pitch=pitch)
 
-                scene = g.trimesh.Scene(surface)
-                assert len(scene.geometry) == 1
-                assert g.np.allclose(scene.bounds, surface.bounds)
-
                 # make sure the voxelized pitch is similar to passed
                 assert g.np.allclose(surface.pitch, pitch)