Repository: mikedh/trimesh

Issue:
Apply_scale doesn't work (as expected) for trimesh.primitives
Following setup:

```
import trimesh
s = trimesh.primitives.Sphere()
s.extents
# array([2., 2., 2.])    -- Fine
s.apply_scale(0.5)
s.extents
# array([2., 2., 2.])    -- Not fine
s.primitive.radius
# 1.0 -- Also not fine
```
I think this is due to the fact that the scaling information is encoded in the transform matrix:
```
s.transform
# TrackedArray([[0.5, 0.  , 0.  , 0.  ],
#              [0.  , 0.5, 0.  , 0.  ],
#              [0.  , 0.  , 0.5, 0.  ],
#              [0.  , 0.  , 0.  , 1.  ]])
```

But I think it would be much more helpful if the parameters were updated accordingly by e.g. overriding apply_scale().

I deal with this problem (having primitives and Trimeshes in the same scene) currently by a big if-else-branch.

Any thoughts?   Thanks!

Discussion:

Patch:
diff --git a/Dockerfile b/Dockerfile
index 13fc6f47..ede36949 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.10-slim-bullseye AS base
+FROM python:3.11-slim-bullseye AS base
 LABEL maintainer="mikedh@kerfed.com"
 
 # Install the llvmpipe software renderer
diff --git a/docs/docker.md b/docs/docker.md
index 9f130f05..d2e91aef 100644
--- a/docs/docker.md
+++ b/docs/docker.md
@@ -11,14 +11,14 @@ Typically when writing Dockerfiles it is a good idea to use a first-party base i
 
 It should generally work fine to just use a first party base image and install trimesh via `pip` which always has the latest version:
 ```
-FROM python:3.10-slim-bullseye
+FROM python:3.11-slim-bullseye
 RUN pip install trimesh[easy]
 ```
 
 
 ### Using Prebuilt Images
 
-The `trimesh/trimesh` docker images are based on the offical Python base image, currently `python:3.10-slim-bullseye`. They are built and pushed to Docker Hub automatically in Github Actions for every release. 
+The `trimesh/trimesh` docker images are based on the offical Python base image, currently `python:3.11-slim-bullseye`. They are built and pushed to Docker Hub automatically in Github Actions for every release. 
 
 If you need some of the more demanding dependencies they can be a good option. The `trimesh/trimesh` images are pushed with three tags: `latest` (for latest :), semantic version (i.e. `3.15.5`), or git short hash (i.e. `1c6178d`). These images include `embree` and `trimesh[all]` which is run in a multi-stage build to avoid including intermediate files in the final image.
 
@@ -41,7 +41,7 @@ docker run -t example
 
 ### Building Trimesh Images
 
-Trimesh is using a multistage build to avoid copying in things like `g++`, so you have to explicitly specify that you want to build the `output` target. You also probably neeed to enable BuildKit:
+Trimesh is using a multistage build to avoid copying in things like `g++`, so you have to explicitly specify that you want to build the `output` target. You also probably need to enable BuildKit:
 
 ```
 DOCKER_BUILDKIT=1 docker build --target output -t trimesh/trimesh:latest .
diff --git a/trimesh/base.py b/trimesh/base.py
index 6d6c985e..25031ca1 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -110,7 +110,7 @@ class Trimesh(Geometry3D):
         # self._cache stores information about the mesh which CAN be
         # regenerated from self._data, but may be slow to calculate.
         # In order to maintain consistency
-        # the cache is cleared when self._data.crc() changes
+        # the cache is cleared when self._data.__hash__() changes
         self._cache = caching.Cache(
             id_function=self._data.__hash__,
             force_immutable=True)
@@ -2477,9 +2477,21 @@ class Trimesh(Geometry3D):
             vertices=open3d.utility.Vector3dVector(self.vertices),
             triangles=open3d.utility.Vector3iVector(self.faces))
 
-    def simplify_quadratic_decimation(self, face_count):
+    def simplify_quadratic_decimation(self, *args, **kwargs):
         """
-        A thin wrapper around the open3d implementation of this:
+        DERECATED MARCH 2024 REPLACE WITH:
+        `mesh.simplify_quadric_decimation`
+        """
+        warnings.warn(
+            '`simplify_quadratic_decimation` is deprecated ' +
+            'as it was a typo and will be removed in March 2024: ' +
+            'replace with `simplify_quadric_decimation`',
+            DeprecationWarning)
+        return self.simplify_quadric_decimation(*args, **kwargs)
+
+    def simplify_quadric_decimation(self, face_count):
+        """
+        A thin wrapper around the `open3d` implementation of this:
         `open3d.geometry.TriangleMesh.simplify_quadric_decimation`
 
         Parameters
diff --git a/trimesh/caching.py b/trimesh/caching.py
index a3e16248..a333d62d 100644
--- a/trimesh/caching.py
+++ b/trimesh/caching.py
@@ -361,13 +361,13 @@ class Cache(object):
           If set will make all numpy arrays read-only
         """
         self._id_function = id_function
-        # force stored numpy arrays to have flags.writable=False
+        # for stored numpy arrays set `flags.writable = False`
         self.force_immutable = bool(force_immutable)
         # call the id function for initial value
         self.id_current = self._id_function()
         # a counter for locks
         self._lock = 0
-        # actuSal store for data
+        # actual store for data
         self.cache = {}
 
     def delete(self, key):
diff --git a/trimesh/exchange/threedxml.py b/trimesh/exchange/threedxml.py
index 452969c9..2bf466e1 100644
--- a/trimesh/exchange/threedxml.py
+++ b/trimesh/exchange/threedxml.py
@@ -152,7 +152,7 @@ def load_3DXML(file_obj, *args, **kwargs):
                 strips = [np.fromstring(i, sep=' ', dtype=np.int64)
                           for i in faces.attrib['strips'].split(',')]
 
-                # convert strips to (m,3) int
+                # convert strips to (m, 3) int
                 mesh_faces.append(util.triangle_strips_to_faces(strips))
             if 'triangles' in faces.attrib:
                 # both triangles and strips are allowed to be defined so
@@ -174,7 +174,7 @@ def load_3DXML(file_obj, *args, **kwargs):
                 sep=' ',
                 dtype=np.float64).reshape((-1, 3)))
 
-            # store the material information as (m,3) uint8 FACE COLORS
+            # store the material information as (m, 3) uint8 FACE COLORS
             mesh_colors.append(np.tile(colors[material_id],
                                        (len(mesh_faces[-1]), 1)))
 
diff --git a/trimesh/geometry.py b/trimesh/geometry.py
index 3bbe99f6..9ac5da95 100644
--- a/trimesh/geometry.py
+++ b/trimesh/geometry.py
@@ -162,35 +162,48 @@ def triangulate_quads(quads, dtype=np.int64):
     faces : (m, 3) int
       Vertex indices of triangular faces.c
     """
-    quads = np.asanyarray(quads)
+
     if len(quads) == 0:
-        return quads.astype(dtype)
-    elif len(quads.shape) == 2 and quads.shape[1] == 3:
-        # if they are just triangles return immediately
-        return quads.astype(dtype)
-    elif len(quads.shape) == 2 and quads.shape[1] == 4:
-        # if they are just quads stack and return
-        return np.vstack((quads[:, [0, 1, 2]],
-                          quads[:, [2, 3, 0]])).astype(dtype)
-    else:
-        # mixed tris, and quads, and other so filter and handle
-        tri = np.array([i for i in quads if len(i) == 3])
-        quad = np.array([i for i in quads if len(i) == 4])
-        # triangulate arbitrary polygons as fans
-        poly = [[[f[0], f[i + 1], f[i + 2]]
-                 for i in range(len(f) - 2)]
-                for f in quads if len(f) > 4]
-
-        if len(quad) == 0 and len(poly) == 0:
-            return tri.astype(dtype)
-        if len(poly) > 0:
-            poly = np.vstack(poly)
-        if len(quad) > 0:
-            quad = np.vstack((quad[:, [0, 1, 2]],
-                              quad[:, [2, 3, 0]]))
-        # combine triangulated quads with triangles
-        return util.vstack_empty([
-            tri, quad, poly]).astype(dtype)
+        return np.zeros(0, dtype=dtype)
+
+    try:
+        # this will fail in newer versions of numpy
+        # if there are mixed quads and tris
+        quads = np.array(quads, dtype=dtype)
+
+        if len(quads.shape) == 2 and quads.shape[1] == 3:
+            # if they are just triangles return immediately
+            return quads.astype(dtype)
+
+        if len(quads.shape) == 2 and quads.shape[1] == 4:
+            # if they are just quads stack and return
+            return np.vstack((quads[:, [0, 1, 2]],
+                              quads[:, [2, 3, 0]])).astype(dtype)
+    except ValueError:
+        # new numpy raises an error for sequences
+        pass
+
+    # we made it here so we have mixed tris/quads/polygons
+    # filter into the three cases
+    tri = np.array([i for i in quads if len(i) == 3])
+    quad = np.array([i for i in quads if len(i) == 4])
+    # triangulate arbitrary polygons as triangle fans
+    # this isn't guarenteed to be sane if the polygons
+    # aren't convex but that would require a real maniac
+    poly = [[[f[0], f[i + 1], f[i + 2]]
+             for i in range(len(f) - 2)]
+            for f in quads if len(f) > 4]
+
+    if len(quad) == 0 and len(poly) == 0:
+        return tri.astype(dtype)
+    if len(poly) > 0:
+        poly = np.vstack(poly)
+    if len(quad) > 0:
+        quad = np.vstack((quad[:, [0, 1, 2]],
+                          quad[:, [2, 3, 0]]))
+    # combine triangulated quads with triangles
+    return util.vstack_empty([
+        tri, quad, poly]).astype(dtype)
 
 
 def vertex_face_indices(vertex_count,
diff --git a/trimesh/interfaces/gmsh.py b/trimesh/interfaces/gmsh.py
index e4c4bdf4..ebabcee0 100644
--- a/trimesh/interfaces/gmsh.py
+++ b/trimesh/interfaces/gmsh.py
@@ -135,7 +135,7 @@ def to_volume(mesh,
       Maximum length of an element in the volume mesh
     mesher_id : int
       3D unstructured algorithms:
-      1: Delaunay, 4: Frontal, 7: MMG3D, 10: HXT
+      1: Delaunay, 3: Initial mesh only, 4: Frontal, 7: MMG3D, 9: R-tree, 10: HXT
 
     Returns
     ------------
@@ -147,8 +147,8 @@ def to_volume(mesh,
     import gmsh
 
     # checks mesher selection
-    if mesher_id not in [1, 4, 7, 10]:
-        raise ValueError('unavilable mesher selected!')
+    if mesher_id not in [1, 3, 4, 7, 9, 10]:
+        raise ValueError('unavailable mesher selected!')
     else:
         mesher_id = int(mesher_id)
 
diff --git a/trimesh/permutate.py b/trimesh/permutate.py
index 30dfe32c..d2928624 100644
--- a/trimesh/permutate.py
+++ b/trimesh/permutate.py
@@ -29,8 +29,8 @@ def transform(mesh, translation_scale=1000.0):
       and rigidly transformed in space.
     """
     # rotate and translate randomly
-    matrix = transformations.random_rotation_matrix()
-    matrix[:3, 3] = (np.random.random(3) - 0.5) * translation_scale
+    matrix = transformations.random_rotation_matrix(
+        translate=translation_scale)
 
     # randomly re-order triangles
     triangles = np.random.permutation(mesh.triangles).reshape((-1, 3))
diff --git a/trimesh/points.py b/trimesh/points.py
index d9902922..301ee39e 100644
--- a/trimesh/points.py
+++ b/trimesh/points.py
@@ -418,7 +418,7 @@ class PointCloud(Geometry3D):
           Metadata about points
         """
         self._data = caching.DataStore()
-        self._cache = caching.Cache(self._data.hash)
+        self._cache = caching.Cache(self._data.__hash__)
         self.metadata = {}
 
         if metadata is not None:
diff --git a/trimesh/primitives.py b/trimesh/primitives.py
index 8e353612..57bf58c0 100644
--- a/trimesh/primitives.py
+++ b/trimesh/primitives.py
@@ -8,7 +8,6 @@ Useful because you can move boxes and spheres around
 and then use trimesh operations on them at any point.
 """
 import numpy as np
-import copy
 import abc
 
 from . import util
@@ -22,6 +21,10 @@ from . import transformations as tf
 from .base import Trimesh
 from .constants import log, tol
 
+# immutable identity matrix for checks
+_IDENTITY = np.eye(4)
+_IDENTITY.flags.writeable = False
+
 
 class _Primitive(Trimesh):
     """
@@ -37,9 +40,13 @@ class _Primitive(Trimesh):
         super(_Primitive, self).__init__(*args, **kwargs)
         self._data.clear()
         self._validate = False
+        # make sure any cached numpy arrays have
+        # set `array.flags.writable = False`
+        self._cache.force_immutable = True
 
     def __repr__(self):
-        return '<trimesh.primitives.{}>'.format(type(self).__name__)
+        return '<trimesh.primitives.{}>'.format(
+            type(self).__name__)
 
     @property
     def faces(self):
@@ -51,7 +58,7 @@ class _Primitive(Trimesh):
 
     @faces.setter
     def faces(self, values):
-        log.warning('Primitive faces are immutable! Not setting!')
+        log.warning('primitive faces are immutable: not setting!')
 
     @property
     def vertices(self):
@@ -65,7 +72,7 @@ class _Primitive(Trimesh):
     @vertices.setter
     def vertices(self, values):
         if values is not None:
-            log.warning('Primitive vertices are immutable! Not setting!')
+            log.warning('primitive vertices are immutable: not setting!')
 
     @property
     def face_normals(self):
@@ -115,7 +122,12 @@ class _Primitive(Trimesh):
         copied : object
           Copy of current primitive
         """
-        return copy.deepcopy(self)
+        # get the constructor arguments
+        kwargs.update(self.to_dict())
+        # remove the type indicator, i.e. `Cylinder`
+        kwargs.pop('kind')
+        # create a new object with kwargs
+        return type(self)(**kwargs)
 
     def to_mesh(self):
         """
@@ -136,23 +148,55 @@ class _Primitive(Trimesh):
     def apply_transform(self, matrix):
         """
         Apply a transform to the current primitive by
-        setting self.transform
+        applying a new transform on top of existing
+        `self.primitive.transform`. If the matrix
+        contains scaling it will change parameters
+        like `radius` or `height` automatically.
 
         Parameters
         ------------
-        matrix: (4,4) float
+        matrix: (4, 4) float
           Homogeneous transformation
         """
         matrix = np.asanyarray(
             matrix, order='C', dtype=np.float64)
         if matrix.shape != (4, 4):
-            raise ValueError('Transformation matrix must be (4,4)!')
-        if util.allclose(matrix, np.eye(4), 1e-8):
-            log.debug('apply_transform received identity matrix')
+            raise ValueError('matrix must be `(4, 4)`!')
+        if util.allclose(matrix, _IDENTITY, 1e-8):
+            # identity matrix is a no-op
             return
 
-        new_transform = np.dot(matrix, self.primitive.transform)
-        self.primitive.transform = new_transform
+        prim = self.primitive
+        # copy the current transform
+        current = prim.transform.copy()
+        # see if matrix has scaling from the matrix
+        scale, factor, origin = tf.scale_from_matrix(matrix)
+        # the objects we handle re-scaling for
+        # note that `Extrusion` is NOT supported
+        kinds = (Box, Cylinder, Capsule, Sphere)
+        if isinstance(self, kinds) and abs(scale - 1.0) > 1e-8:
+            # scale the primitive attributes
+            if hasattr(prim, 'height'):
+                prim.height *= scale
+            if hasattr(prim, 'radius'):
+                prim.radius *= scale
+            if hasattr(prim, 'extents'):
+                prim.extents *= scale
+            # scale the translation of the current matrix
+            current[:3, 3] *= scale
+            # apply new matrix, rescale, translate, current
+            updated = util.multi_dot([
+                matrix,
+                tf.scale_matrix(1.0 / scale),
+                current])
+        else:
+            # without scaling just multiply
+            updated = np.dot(matrix, current)
+        # make sure matrix is a rigid transform
+        assert tf.is_rigid(updated)
+        # apply the new matrix
+        self.primitive.transform = updated
+
         return self
 
     def _create_mesh(self):
@@ -165,19 +209,39 @@ class _PrimitiveAttributes(object):
     """
 
     def __init__(self, parent, defaults, kwargs):
+        """
+        Hold the attributes for a Primitive.
+
+        Parameters
+        ------------
+        parent : _Primitive
+          Parent object reference.
+        defaults : dict
+          The default values for this primitive type.
+        kwargs : dict
+          User-passed values, i.e. {'radius': 10.0}
+        """
+        # store actual data in parent object
         self._data = parent._data
+        # default values define the keys
         self._defaults = defaults
+        # store a reference to the parent ubject
         self._parent = parent
+        # start with a copy of all default objects
         self._data.update(defaults)
-        self._mutable = True
-        for key, value in kwargs.items():
-            if key in defaults:
-                self._data[key] = util.convert_like(
-                    value, defaults[key])
-        # if configured as immutable apply setting after
-        # instantiation values are set
-        if 'mutable' in kwargs:
-            self._mutable = bool(kwargs['mutable'])
+        # store whether this data is mutable after creation
+        self._mutable = kwargs.get('mutable', True)
+        # assign the keys passed by the user only if
+        # they are a property of this primitive
+        for key, default in defaults.items():
+            value = kwargs.get(key, None)
+            if value is not None:
+                # convert passed data into type of defaults
+                self._data[key] = util.convert_like(value, default)
+
+        # make sure stored values are immutable after setting
+        if not self._mutable:
+            self._data.mutable = False
 
     @property
     def __doc__(self):
@@ -206,7 +270,6 @@ class _PrimitiveAttributes(object):
         elif key == 'center':
             # this whole __getattr__ is a little hacky
             return self._data['transform'][:3, 3]
-
         elif key in self._defaults:
             return util.convert_like(self._data[key],
                                      self._defaults[key])
@@ -214,7 +277,6 @@ class _PrimitiveAttributes(object):
             "primitive object has no attribute '{}' ".format(key))
 
     def __setattr__(self, key, value):
-
         if key.startswith('_'):
             return super(_PrimitiveAttributes,
                          self).__setattr__(key, value)
@@ -290,7 +352,8 @@ class Cylinder(_Primitive):
 
         Returns
         ----------
-        tensor: (3,3) float, 3D inertia tensor
+        tensor: (3, 3) float
+          3D inertia tensor
         """
 
         tensor = inertia.cylinder_inertia(
@@ -391,10 +454,14 @@ class Capsule(_Primitive):
 
         Parameters
         ----------
-        radius: float, radius of cylinder
-        height: float, height of cylinder
-        transform: (4,4) float, transformation matrix
-        sections: int, number of facets in circle
+        radius : float
+          Radius of cylinder
+        height : float
+          Height of cylinder
+        transform : (4, 4) float
+          Transformation matrix
+        sections : int
+          Number of facets in circle
         """
         super(Capsule, self).__init__(*args, **kwargs)
 
@@ -433,13 +500,15 @@ class Capsule(_Primitive):
 
         Returns
         --------
-        axis: (3,) float, vector along the cylinder axis
+        axis : (3,) float
+          Vector along the cylinder axis
         """
-        axis = np.dot(self.primitive.transform, [0, 0, 1, 0])[:3]
+        axis = np.dot(self.primitive.transform,
+                      [0, 0, 1, 0])[:3]
         return axis
 
     def _create_mesh(self):
-        log.debug('creating mesh for Capsule primitive')
+        log.debug('creating mesh for `Capsule` primitive')
 
         mesh = creation.capsule(radius=self.primitive.radius,
                                 height=self.primitive.height)
@@ -469,10 +538,18 @@ class Sphere(_Primitive):
         defaults = {'radius': 1.0,
                     'transform': np.eye(4),
                     'subdivisions': 3}
+
+        # center is a helper method for "transform"
+        # since a sphere is rotationally symmetric
+        center = kwargs.get('center', None)
+        if center is not None:
+            translate = np.eye(4)
+            translate[:3, 3] = center
+            kwargs['transform'] = translate
+
+        # create the attributes object
         self.primitive = _PrimitiveAttributes(
             self, defaults, kwargs)
-        if 'center' in kwargs:
-            self.primitive.center = kwargs['center']
 
     @property
     def center(self):
@@ -547,11 +624,12 @@ class Sphere(_Primitive):
 
         Returns
         ----------
-        tensor: (3,3) float, 3D inertia tensor
+        tensor: (3, 3) float
+          3D inertia tensor.
         """
-        tensor = inertia.sphere_inertia(mass=self.volume,
-                                        radius=self.primitive.radius)
-        return tensor
+        return inertia.sphere_inertia(
+            mass=self.volume,
+            radius=self.primitive.radius)
 
     def _create_mesh(self):
         log.debug('creating mesh for Sphere primitive')
@@ -721,7 +799,7 @@ class Extrusion(_Primitive):
         ----------
         polygon : shapely.geometry.Polygon
           Polygon to extrude
-        transform : (4,4) float
+        transform : (4, 4) float
           Transform to apply after extrusion
         height : float
           Height to extrude polygon by
diff --git a/trimesh/resolvers.py b/trimesh/resolvers.py
index 71834eeb..3d116dd7 100644
--- a/trimesh/resolvers.py
+++ b/trimesh/resolvers.py
@@ -36,18 +36,23 @@ class Resolver(util.ABC):
     def get(self, key):
         raise NotImplementedError()
 
+    @abc.abstractmethod
     def write(self, name, data):
         raise NotImplementedError('`write` not implemented!')
 
+    @abc.abstractmethod
     def namespaced(self, namespace):
         raise NotImplementedError('`namespaced` not implemented!')
 
     def __getitem__(self, key):
         return self.get(key)
 
-    def __setitem(self, key, value):
+    def __setitem__(self, key, value):
         return self.write(key, value)
 
+    def __contains__(self, key):
+        return key in self.keys()
+
 
 class FilePathResolver(Resolver):
     """
@@ -91,10 +96,33 @@ class FilePathResolver(Resolver):
         name : str
           Name of a file which can be accessed.
         """
+        parent = self.parent
         for path, _, names in os.walk(self.parent):
+            # strip any leading parent key
+            if path.startswith(parent):
+                path = path[len(parent):]
+            # yield each name
             for name in names:
                 yield os.path.join(path, name)
 
+    def namespaced(self, namespace):
+        """
+        Return a resolver which changes the root of the
+        resolver by an added namespace.
+
+        Parameters
+        -------------
+        namespace : str
+          Probably a subdirectory
+
+        Returns
+        --------------
+        resolver : FilePathResolver
+          Resolver with root directory changed.
+        """
+        return FilePathResolver(os.path.join(
+            self.parent, namespace))
+
     def get(self, name):
         """
         Get an asset.
@@ -140,7 +168,7 @@ class ZipResolver(Resolver):
     Resolve files inside a ZIP archive.
     """
 
-    def __init__(self, archive, namespace=None):
+    def __init__(self, archive=None, namespace=None):
         """
         Resolve files inside a ZIP archive as loaded by
         trimesh.util.decompress
@@ -161,9 +189,17 @@ class ZipResolver(Resolver):
             self.namespace = None
 
     def keys(self):
+        """
+        Get the available keys in the current archive.
+
+        Returns
+        -----------
+        keys : iterable
+          Keys in the current archive.
+        """
         if self.namespace is not None:
             namespace = self.namespace
-            length = len(namespace) + 1
+            length = len(namespace)
             # only return keys that start with the namespace
             # and strip off the namespace from the returned
             # keys.
@@ -172,6 +208,21 @@ class ZipResolver(Resolver):
                     and len(k) > length]
         return self.archive.keys()
 
+    def write(self, key, value):
+        """
+        Store a value in the current archive.
+
+        Parameters
+        -----------
+        key : hashable
+          Key to store data under.
+        value : str, bytes, file-like
+          Value to store.
+        """
+        if self.archive is None:
+            self.archive = {}
+        self.archive[key] = value
+
     def get(self, name):
         """
         Get an asset from the ZIP archive.
@@ -237,6 +288,18 @@ class ZipResolver(Resolver):
         return ZipResolver(archive=self.archive,
                            namespace=namespace)
 
+    def export(self):
+        """
+        Export the contents of the current archive as
+        a ZIP file.
+
+        Returns
+        ------------
+        compressed : bytes
+          Compressed data in ZIP format.
+        """
+        return util.compress(self.archive)
+
 
 class WebResolver(Resolver):
     """
@@ -259,11 +322,18 @@ class WebResolver(Resolver):
         # parse string into namedtuple
         parsed = urlparse(url)
 
-        # we want a base url where the mesh was located
-        path = parsed.path
-        if path[-1] != '/':
+        # we want a base url
+        split = [i for i in parsed.path.split('/')
+                 if len(i) > 0]
+
+        # if the last item in the url path is a filename
+        # move up a "directory" for the base path
+        if len(split) > 0 and '.' in split[-1]:
             # clip off last item
-            path = '/'.join(path.split('/')[:-1]) + '/'
+            path = '/'.join(split[:-1])
+        else:
+            # recombine into string ignoring any double slashes
+            path = '/'.join(split)
 
         # store the base url
         self.base_url = '{scheme}://{netloc}/{path}'.format(
@@ -291,9 +361,10 @@ class WebResolver(Resolver):
 
         if response.status_code != 200:
             # try to strip off filesystem crap
+            if name.startswith('./'):
+                name = name[2:]
             response = requests.get(urljoin(
-                self.base_url,
-                name.lstrip('./')))
+                self.base_url, name))
 
         if response.status_code == '404':
             raise ValueError(response.content)
@@ -301,6 +372,27 @@ class WebResolver(Resolver):
         # return the bytes of the response
         return response.content
 
+    def namespaced(self, namespace):
+        """
+        Return a namespaced version of current resolver.
+
+        Parameters
+        -------------
+        namespace : str
+          URL fragment
+
+        Returns
+        -----------
+        resolver : WebResolver
+          With sub-url: `https://example.com/{namespace}`
+        """
+        # join the base url and the namespace
+        return WebResolver(url=urljoin(
+            self.base_url, namespace))
+
+    def write(self, key, value):
+        raise NotImplementedError("can't write to remote")
+
 
 class GithubResolver(Resolver):
     def __init__(self,
@@ -352,6 +444,9 @@ class GithubResolver(Resolver):
         """
         return self.zipped.keys()
 
+    def write(self, name, data):
+        raise NotImplementedError('`write` not implemented!')
+
     @property
     def zipped(self):
         """
@@ -418,11 +513,16 @@ def nearby_names(name, namespace=None):
       of the initial name.
     """
     # the various operations that *might* result in a correct key
+    def trim(prefix, item):
+        if item.startswith(prefix):
+            return item[len(prefix):]
+        return item
+
     cleaners = [lambda x: x,
                 lambda x: x.strip(),
-                lambda x: x.lstrip('./'),
-                lambda x: x.lstrip('.\\'),
-                lambda x: x.lstrip('\\'),
+                lambda x: trim('./', x),
+                lambda x: trim('.\\', x),
+                lambda x: trim('\\', x),
                 lambda x: os.path.split(x)[-1],
                 lambda x: x.replace('%20', ' ')]
 
diff --git a/trimesh/transformations.py b/trimesh/transformations.py
index 845e10bc..11c19268 100644
--- a/trimesh/transformations.py
+++ b/trimesh/transformations.py
@@ -1567,7 +1567,7 @@ def random_quaternion(rand=None, num=1):
                      np.cos(t1) * r1, np.sin(t2) * r2]).T.squeeze()
 
 
-def random_rotation_matrix(rand=None, num=1):
+def random_rotation_matrix(rand=None, num=1, translate=False):
     """Return uniform random rotation matrix.
 
     rand: array like
@@ -1582,7 +1582,12 @@ def random_rotation_matrix(rand=None, num=1):
     True
 
     """
-    return quaternion_matrix(random_quaternion(rand=rand, num=num))
+    matrix = quaternion_matrix(
+        random_quaternion(rand=rand, num=num))
+    if translate:
+        scale = float(translate)
+        matrix[:3, 3] = (np.random.random(3) - 0.5) * scale
+    return matrix
 
 
 class Arcball(object):
diff --git a/trimesh/version.py b/trimesh/version.py
index 299ce3c3..8af81627 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,4 +1,4 @@
-__version__ = '3.17.1'
+__version__ = '3.18.0'
 
 if __name__ == '__main__':
     # print version if run directly i.e. in a CI script
diff --git a/trimesh/visual/base.py b/trimesh/visual/base.py
index e5e2d677..02246625 100644
--- a/trimesh/visual/base.py
+++ b/trimesh/visual/base.py
@@ -17,11 +17,11 @@ class Visuals(ABC):
         pass
 
     @abc.abstractmethod
-    def update_vertices(self):
+    def update_vertices(self, mask):
         pass
 
     @abc.abstractmethod
-    def update_faces(self):
+    def update_faces(self, mask):
         pass
 
     @abc.abstractmethod


Test patch:
diff --git a/tests/corpus.py b/tests/corpus.py
index eb64af43..888877df 100644
--- a/tests/corpus.py
+++ b/tests/corpus.py
@@ -179,7 +179,8 @@ if __name__ == '__main__':
             repo='ros-industrial/universal_robot',
             commit='8f01aa1934079e5a2c859ccaa9dd6623d4cfa2fe'))
 
-    P.print()
+    # show all profiler lines
+    P.print(show_all=True)
 
     # print a formatted report of what we loaded
     print('\n'.join(f'# {k}\n{v}\n' for k, v in report.items()))
diff --git a/tests/generic.py b/tests/generic.py
index a44c40ae..27725e33 100644
--- a/tests/generic.py
+++ b/tests/generic.py
@@ -31,6 +31,7 @@ from trimesh.constants import tol, tol_path
 from collections import deque
 from copy import deepcopy
 
+tf = trimesh.transformations
 
 if sys.version_info >= (3, 1):
     # Python 3
diff --git a/tests/test_obj.py b/tests/test_obj.py
index 670d4a6b..82eb084b 100644
--- a/tests/test_obj.py
+++ b/tests/test_obj.py
@@ -348,6 +348,22 @@ class OBJTest(g.unittest.TestCase):
         assert g.np.isclose(m.visual.material.glossiness,
                             r.visual.material.glossiness)
 
+    def test_scene_export_material_name(self):
+        s = g.get_mesh('fuze.obj', force='scene')
+        dummy = 'fuxx'
+        s.geometry['fuze.obj'].visual.material.name = dummy
+
+        r = g.trimesh.resolvers.ZipResolver()
+        r['model.obj'] = s.export(
+            file_type='obj',
+            mtl_name='mystuff.mtl',
+            resolver=r)
+
+        mtl = r['mystuff.mtl'].decode('utf-8')
+        assert mtl.count('newmtl') == 1
+        assert 'newmtl {}'.format(dummy) in mtl
+        assert '{}.jpeg'.format(dummy) in r
+
     def test_compound_scene_export(self):
 
         # generate a mesh with multiple textures
diff --git a/tests/test_primitives.py b/tests/test_primitives.py
index 41bdc304..5f19be24 100644
--- a/tests/test_primitives.py
+++ b/tests/test_primitives.py
@@ -62,6 +62,92 @@ class PrimitiveTest(g.unittest.TestCase):
         self.primitives.append(g.trimesh.primitives.Capsule(radius=1.5,
                                                             height=10))
 
+    def test_scaling(self):
+        # try a simple scaling test
+        p = g.trimesh.primitives.Sphere(radius=1.0)
+        m = p.to_mesh()
+        assert g.np.allclose(p.extents, 2.0)
+        assert g.np.allclose(m.extents, 2.0)
+        p.apply_scale(0.5)
+        m.apply_scale(0.5)
+        assert g.np.isclose(p.primitive.radius, 0.5)
+        assert g.np.allclose(p.extents, 1.0)
+        assert g.np.allclose(m.extents, 1.0)
+        assert g.np.allclose(p.extents, m.extents, atol=1e-3)
+
+        p = g.trimesh.primitives.Box()
+        p.apply_translation([0.5, 0.5, 0.5])
+        p.apply_scale(5.0)
+        assert g.np.allclose(p.bounds, [[0, 0, 0], [5, 5, 5]])
+
+        try:
+            raised = False
+            p.apply_scale([2, 3, 4])
+        except BaseException:
+            raised = True
+        if not raised:
+            raise ValueError('primitives should raise on non-uniform scaling')
+
+        # now try with more complicated generated data
+        prims = [g.trimesh.primitives.Sphere(radius=1.0),
+                 g.trimesh.primitives.Sphere(radius=112.007),
+                 g.trimesh.primitives.Cylinder(radius=1.0,
+                                               height=10.0),
+                 g.trimesh.primitives.Box(),
+                 g.trimesh.primitives.Box(extents=[12, 32, 31]),
+                 g.trimesh.primitives.Cylinder(radius=1.1212,
+                                               height=0.001),
+                 g.trimesh.primitives.Capsule(radius=1.0,
+                                              height=7.0)]
+
+        for original in prims:
+            perm = [original, original.copy(), original.copy()]
+            # try with a simple translation
+            perm[1].primitive.transform = g.tf.translation_matrix([0, 0, 7])
+            # try with a gnarly rotation
+            perm[2].primitive.transform = g.tf.random_rotation_matrix(
+                translate=1000)
+
+            fields = set(dir(original.primitive))
+            ori_radius, ori_height = None, None
+            if 'radius' in fields:
+                ori_radius = original.primitive.radius
+            if 'height' in fields:
+                ori_height = original.primitive.height
+
+            for scale in [1e-2, 0.123, 0.5, 100.2]:
+                for po in perm:
+                    # converting to mesh will do all scaling
+                    # and transformations on simple discrete
+                    # copy of primitive and should match with
+                    # only tesselation differences
+                    p = po.copy()
+                    m = p.to_mesh()
+
+                    # make sure we have the types we think we do
+                    assert isinstance(p, g.trimesh.primitives._Primitive)
+                    assert isinstance(m, g.trimesh.Trimesh)
+
+                    assert g.np.allclose(p.extents, m.extents)
+
+                    p.apply_scale(scale)
+                    m.apply_scale(scale)
+
+                    # matrix should never have scale
+                    assert g.tf.is_rigid(p.primitive.transform)
+
+                    if ori_radius is not None:
+                        assert g.np.isclose(p.primitive.radius,
+                                            ori_radius * scale)
+                    if ori_height is not None:
+                        assert g.np.isclose(p.primitive.height,
+                                            ori_height * scale)
+
+                    # should be the same size
+                    assert g.np.allclose(p.extents, m.extents, atol=1e-3 * scale)
+                    # should be in the same place
+                    assert g.np.allclose(p.bounds, m.bounds, atol=1e-3 * scale)
+
     def test_primitives(self):
 
         kind = set([i.__class__.__name__
diff --git a/tests/test_resolvers.py b/tests/test_resolvers.py
new file mode 100644
index 00000000..42a51e29
--- /dev/null
+++ b/tests/test_resolvers.py
@@ -0,0 +1,70 @@
+try:
+    from . import generic as g
+except BaseException:
+    import generic as g
+
+
+class ResolverTest(g.unittest.TestCase):
+
+    def test_filepath_namespace(self):
+        # check the namespaced method
+        models = g.dir_models
+        subdir = '2D'
+
+        # create a resolver for the models diretory
+        resolver = g.trimesh.resolvers.FilePathResolver(models)
+
+        # should be able to get an asset
+        assert len(resolver.get('rabbit.obj')) > 0
+
+        # check a few file path keys
+        check = set(['ballA.off', 'featuretype.STL'])
+        assert set(resolver.keys()).issuperset(check)
+
+        # try a namespaced resolver
+        ns = resolver.namespaced(subdir)
+        assert not set(ns.keys()).issuperset(check)
+        assert set(ns.keys()).issuperset(['tray-easy1.dxf',
+                                          'single_arc.dxf'])
+
+    def test_web_namespace(self):
+        base = 'https://example.com'
+        ns = 'stuff'
+        # check with a trailing slash
+        a = g.trimesh.resolvers.WebResolver(base + '/')
+        b = a.namespaced(ns)
+
+        # should have correct slashes
+        truth = base + '/' + ns
+        assert b.base_url == truth
+        # should not have altered original
+        assert a.base_url == base + '/'
+
+    def test_items(self):
+        # check __getitem__ and __setitem__
+        archive = {}
+        resolver = g.trimesh.resolvers.ZipResolver(archive)
+        assert len(set(resolver.keys())) == 0
+        resolver['hi'] = b'what'
+        # should have one item
+        assert set(resolver.keys()) == set(['hi'])
+        # should have the right value
+        assert resolver['hi'] == b'what'
+        # original archive should have been modified
+        assert set(archive.keys()) == set(['hi'])
+
+        # add a subdirectory key
+        resolver['stuff/nah'] = b'sup'
+        assert set(archive.keys()) == set(['hi', 'stuff/nah'])
+        assert set(resolver.keys()) == set(['hi', 'stuff/nah'])
+
+        # try namespacing
+        ns = resolver.namespaced('stuff')
+        assert ns['nah'] == b'sup'
+        print(ns.keys())
+        assert set(ns.keys()) == set(['nah'])
+
+
+if __name__ == '__main__':
+    g.trimesh.util.attach_to_log()
+    g.unittest.main()
diff --git a/tests/test_voxel.py b/tests/test_voxel.py
index efced3a7..334e2ecc 100644
--- a/tests/test_voxel.py
+++ b/tests/test_voxel.py
@@ -2,10 +2,6 @@ try:
     from . import generic as g
 except BaseException:
     import generic as g
-from trimesh.voxel import ops
-from trimesh.voxel import creation
-from trimesh.primitives import Sphere
-import numpy as np
 
 
 class VoxelGridTest(g.unittest.TestCase):
@@ -21,7 +17,7 @@ class VoxelGridTest(g.unittest.TestCase):
                 surface = m.voxelized(pitch=pitch)
 
                 # make sure the voxelized pitch is similar to passed
-                assert np.allclose(surface.pitch, pitch)
+                assert g.np.allclose(surface.pitch, pitch)
 
                 for fill_method in ('base', 'orthographic'):
                     solid = surface.copy().fill(method=fill_method)
@@ -73,10 +69,11 @@ class VoxelGridTest(g.unittest.TestCase):
 
         # make sure offset is correct
         matrix = g.np.ones((3, 3, 3), dtype=bool)
-        mesh = ops.matrix_to_marching_cubes(matrix=matrix)
+        mesh = g.trimesh.voxel.ops.matrix_to_marching_cubes(matrix=matrix)
         assert mesh.is_watertight
 
-        mesh = ops.matrix_to_marching_cubes(matrix=matrix).apply_scale(3.0)
+        mesh = g.trimesh.voxel.ops.matrix_to_marching_cubes(
+            matrix=matrix).apply_scale(3.0)
         assert mesh.is_watertight
 
     def test_marching_points(self):
@@ -94,7 +91,7 @@ class VoxelGridTest(g.unittest.TestCase):
         # make the pitch proportional to scale
         pitch = points.ptp(axis=0).min() / 10
         # run marching cubes
-        mesh = ops.points_to_marching_cubes(
+        mesh = g.trimesh.voxel.ops.points_to_marching_cubes(
             points=points, pitch=pitch)
 
         # mesh should have faces
@@ -107,6 +104,8 @@ class VoxelGridTest(g.unittest.TestCase):
         """
         Try calling local voxel functions
         """
+        from trimesh.voxel import creation
+
         mesh = g.trimesh.creation.box()
 
         # it should have some stuff
@@ -145,13 +144,13 @@ class VoxelGridTest(g.unittest.TestCase):
         indices = [[0, 0, 0], [0, 6, 4]]
 
         # points -> indices
-        indices2 = ops.points_to_indices(
+        indices2 = g.trimesh.voxel.ops.points_to_indices(
             points=points, origin=origin, pitch=pitch)
 
         g.np.testing.assert_allclose(indices, indices2, atol=0, rtol=0)
 
         # indices -> points
-        points2 = ops.indices_to_points(
+        points2 = g.trimesh.voxel.ops.indices_to_points(
             indices=indices, origin=origin, pitch=pitch)
         g.np.testing.assert_allclose(
             g.np.array(indices) * pitch + origin, points2, atol=0, rtol=0)
@@ -159,9 +158,9 @@ class VoxelGridTest(g.unittest.TestCase):
             points, points2, atol=pitch / 2 * 1.01, rtol=0)
 
         # indices -> points -> indices (this must be consistent)
-        points2 = ops.indices_to_points(
+        points2 = g.trimesh.voxel.ops.indices_to_points(
             indices=indices, origin=origin, pitch=pitch)
-        indices2 = ops.points_to_indices(
+        indices2 = g.trimesh.voxel.ops.points_to_indices(
             points=points2, origin=origin, pitch=pitch)
         g.np.testing.assert_allclose(indices, indices2, atol=0, rtol=0)
 
@@ -172,13 +171,13 @@ class VoxelGridTest(g.unittest.TestCase):
         origin = (0, 0, 1)
 
         matrix = g.np.eye(9, dtype=bool).reshape((-1, 3, 3))
-        centers = ops.matrix_to_points(
+        centers = g.trimesh.voxel.ops.matrix_to_points(
             matrix=matrix, pitch=pitch, origin=origin)
         v = voxel.VoxelGrid(matrix).apply_scale(
             pitch).apply_translation(origin)
 
         boxes1 = v.as_boxes()
-        boxes2 = ops.multibox(centers).apply_scale(pitch)
+        boxes2 = g.trimesh.voxel.ops.multibox(centers).apply_scale(pitch)
         colors = [g.trimesh.visual.DEFAULT_COLOR] * matrix.sum() * 12
         for boxes in [boxes1, boxes2]:
             g.np.testing.assert_allclose(
@@ -187,7 +186,7 @@ class VoxelGridTest(g.unittest.TestCase):
         # check assigning a single color
         color = [255, 0, 0, 255]
         boxes1 = v.as_boxes(colors=color)
-        boxes2 = ops.multibox(
+        boxes2 = g.trimesh.voxel.ops.multibox(
             centers=centers, colors=color).apply_scale(pitch)
         colors = g.np.array([color] * len(centers) * 12)
         for boxes in [boxes1, boxes2]:
@@ -322,12 +321,12 @@ class VoxelGridTest(g.unittest.TestCase):
             8, 40, 16], dtype=np.uint8))
         v_rle = voxel.VoxelGrid(rle_obj.reshape(shape))
         self.assertEqual(v_rle.filled_count, 40)
-        np.testing.assert_equal(
+        g.np.testing.assert_equal(
             v_rle.encoding.dense,
-            np.reshape([0] * 8 + [1] * 40 + [0] * 16, shape))
+            g.np.reshape([0] * 8 + [1] * 40 + [0] * 16, shape))
 
         v_brle = voxel.VoxelGrid(brle_obj.reshape(shape))
-        query_points = np.random.uniform(size=(100, 3), high=4)
+        query_points = g.np.random.uniform(size=(100, 3), high=4)
         self._test_equiv(v_rle, v_brle, query_points)
 
     def test_hollow(self):
@@ -335,7 +334,7 @@ class VoxelGridTest(g.unittest.TestCase):
             g.log.warning('no binvox to test!')
             return
 
-        filled = Sphere().voxelized(
+        filled = g.trimesh.primitives.Sphere().voxelized(
             pitch=0.1,
             method='binvox',
             exact=True)
@@ -345,7 +344,7 @@ class VoxelGridTest(g.unittest.TestCase):
 
     def test_fill(self):
         from trimesh.voxel.morphology import fillers
-        hollow = Sphere().voxelized(pitch=0.1).hollow()
+        hollow = g.trimesh.primitives.Sphere().voxelized(pitch=0.1).hollow()
 
         for key in fillers:
             filled = hollow.copy().fill(key)
@@ -356,9 +355,10 @@ class VoxelGridTest(g.unittest.TestCase):
             g.log.warning('no binvox to test!')
             return
 
-        octant = Sphere().voxelized(pitch=0.1,
-                                    method='binvox',
-                                    exact=True)
+        octant = g.trimesh.primitives.Sphere().voxelized(
+            pitch=0.1,
+            method='binvox',
+            exact=True)
         dense = octant.encoding.dense.copy()
         nx, ny, nz = octant.shape
         dense[:nx // 2] = 0
@@ -368,7 +368,7 @@ class VoxelGridTest(g.unittest.TestCase):
         stripped = octant.copy().strip()
         self.assertEqual(octant.filled_count, stripped.filled_count)
         self.assertEqual(octant.volume, stripped.volume)
-        np.testing.assert_allclose(octant.points, stripped.points)
+        g.np.testing.assert_allclose(octant.points, stripped.points)
         self.assertGreater(octant.encoding.size, stripped.encoding.size)
 
     def test_binvox_with_dimension(self):
@@ -377,11 +377,12 @@ class VoxelGridTest(g.unittest.TestCase):
             return
 
         dim = 10
-        octant = Sphere().voxelized(pitch=None,
-                                    dimension=dim,
-                                    method='binvox',
-                                    exact=True)
-        self.assertEqual(octant.shape, (dim,) * 3)
+        octant = g.trimesh.primitives.Sphere().voxelized(
+            pitch=None,
+            dimension=dim,
+            method='binvox',
+            exact=True)
+        assert octant.shape == (dim, dim, dim)
 
 
 if __name__ == '__main__':