Repository: mikedh/trimesh

Issue:
Copying visuals when copying geometry
Everything works as expected when doing the following (the exported sphere is green):
```
s = trimesh.creation.uv_sphere().scene()
s.geometry['geometry_0'].visual.face_colors[:, :3] = [0, 255, 0]
g = s.geometry['geometry_0']
g.export('/tmp/sphere.obj')
```

When doing the same with copying, this doesn't work (the exported sphere is not green):
```
s = trimesh.creation.uv_sphere().scene()
s.geometry['geometry_0'].visual.face_colors[:, :3] = [0, 255, 0]
g = s.geometry['geometry_0'].copy()
g.export('/tmp/sphere.obj')
```
Any idea - is this not a bug?  (the copy() method looks quite evolved and does a deep copy of the visuals....)

Additional hint: if I do the above -- but do `s.show()` before copying -- the exported sphere is green (?):
```
s = trimesh.creation.uv_sphere().scene()
s.geometry['geometry_0'].visual.face_colors[:, :3] = [0, 255, 0]
s.show()
g = s.geometry['geometry_0'].copy()
g.export('/tmp/sphere.obj')
```

How can I emulate whatever `show` does without visualizing the scene?  (is this a cache issue?)

Discussion:

Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 20e263d4..c6ca1bb7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -35,7 +35,7 @@ jobs:
     runs-on: ${{ matrix.os }}
     strategy:
       matrix:
-        python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9]
+        python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10]
         os: [ubuntu-latest, macos-latest, windows-latest]
         exclude:
             # windows runners have gotten very flaky
diff --git a/README.md b/README.md
index b341223b..661c570b 100644
--- a/README.md
+++ b/README.md
@@ -4,11 +4,11 @@
 [![Github Actions](https://github.com/mikedh/trimesh/workflows/Release%20Trimesh/badge.svg)](https://github.com/mikedh/trimesh/actions)  [![PyPI version](https://badge.fury.io/py/trimesh.svg)](https://badge.fury.io/py/trimesh) [![codecov](https://codecov.io/gh/mikedh/trimesh/branch/main/graph/badge.svg?token=4PVRQXyl2h)](https://codecov.io/gh/mikedh/trimesh)
 
 
-Trimesh is a pure Python (2.7-3.4+) library for loading and using [triangular meshes](https://en.wikipedia.org/wiki/Triangle_mesh) with an emphasis on watertight surfaces. The goal of the library is to provide a full featured and well tested Trimesh object which allows for easy manipulation and analysis, in the style of the Polygon object in the [Shapely library](https://github.com/Toblerity/Shapely).
+Trimesh is a pure Python (2.7-3.5+) library for loading and using [triangular meshes](https://en.wikipedia.org/wiki/Triangle_mesh) with an emphasis on watertight surfaces. The goal of the library is to provide a full featured and well tested Trimesh object which allows for easy manipulation and analysis, in the style of the Polygon object in the [Shapely library](https://github.com/Toblerity/Shapely).
 
 The API is mostly stable, but this should not be relied on and is not guaranteed: install a specific version if you plan on deploying something using trimesh.
 
-Pull requests are appreciated and responded to promptly! If you'd like to contribute, here is an [up to date list of potential enhancements](https://github.com/mikedh/trimesh/issues/1557) although things not on that list are also welcome. Here are some [tips for writing applications and contributing.](https://github.com/mikedh/trimesh/blob/main/trimesh/CONTRIBUTING.md)
+Pull requests are appreciated and responded to promptly! If you'd like to contribute, here is an [up to date list of potential enhancements](https://github.com/mikedh/trimesh/issues/1557) although things not on that list are also welcome. Here's a quick [development and contributing guide.](https://trimsh.org/contributing.html)
 
 
 ## Basic Installation
diff --git a/setup.py b/setup.py
index e85da348..071ec358 100644
--- a/setup.py
+++ b/setup.py
@@ -149,11 +149,12 @@ setup(name='trimesh',
           'License :: OSI Approved :: MIT License',
           'Programming Language :: Python',
           'Programming Language :: Python :: 2.7',
-          'Programming Language :: Python :: 3.3',
-          'Programming Language :: Python :: 3.4',
           'Programming Language :: Python :: 3.5',
           'Programming Language :: Python :: 3.6',
           'Programming Language :: Python :: 3.7',
+          'Programming Language :: Python :: 3.8',
+          'Programming Language :: Python :: 3.9',
+          'Programming Language :: Python :: 3.10',
           'Natural Language :: English',
           'Topic :: Scientific/Engineering'],
       packages=[
@@ -170,6 +171,8 @@ setup(name='trimesh',
           'trimesh.interfaces'],
       package_data={'trimesh': ['resources/templates/*',
                                 'resources/*.json',
+                                'resources/schema/*',
+                                'resources/schema/primitive/*.json',
                                 'resources/*.zip']},
       install_requires=list(requirements_default),
       extras_require={'test': list(requirements_test),
diff --git a/trimesh/collision.py b/trimesh/collision.py
index 2257bc35..7c332955 100644
--- a/trimesh/collision.py
+++ b/trimesh/collision.py
@@ -30,9 +30,22 @@ class ContactData(object):
             names[0]: contact.b1,
             names[1]: contact.b2
         }
+        self._normal = contact.normal
         self._point = contact.pos
         self._depth = contact.penetration_depth
 
+    @property
+    def normal(self):
+        """
+        The 3D intersection normal for this contact.
+
+        Returns
+        -------
+        normal : (3,) float
+          The intersection normal.
+        """
+        return self._normal
+
     @property
     def point(self):
         """
@@ -165,9 +178,6 @@ class CollisionManager(object):
         # unpopulated values will return None
         self._names = collections.defaultdict(lambda: None)
 
-        # cache BVH objects
-        # {mesh.md5(): fcl.BVHModel object}
-        self._bvh = {}
         self._manager = fcl.DynamicAABBTreeCollisionManager()
         self._manager.setup()
 
@@ -198,19 +208,20 @@ class CollisionManager(object):
         if transform.shape != (4, 4):
             raise ValueError('transform must be (4,4)!')
 
-        # create or recall from cache BVH
-        bvh = self._get_BVH(mesh)
+        # create BVH/Convex
+        geom = self._get_fcl_obj(mesh)
+
         # create the FCL transform from (4,4) matrix
         t = fcl.Transform(transform[:3, :3], transform[:3, 3])
-        o = fcl.CollisionObject(bvh, t)
+        o = fcl.CollisionObject(geom, t)
 
         # Add collision object to set
         if name in self._objs:
             self._manager.unregisterObject(self._objs[name])
         self._objs[name] = {'obj': o,
-                            'geom': bvh}
+                            'geom': geom}
         # store the name of the geometry
-        self._names[id(bvh)] = name
+        self._names[id(geom)] = name
 
         self._manager.registerObject(o)
         self._manager.update()
@@ -289,10 +300,12 @@ class CollisionManager(object):
         if transform is None:
             transform = np.eye(4)
 
-        # Create FCL data
-        b = self._get_BVH(mesh)
+        # create BVH/Convex
+        geom = self._get_fcl_obj(mesh)
+
+        # create the FCL transform from (4,4) matrix
         t = fcl.Transform(transform[:3, :3], transform[:3, 3])
-        o = fcl.CollisionObject(b, t)
+        o = fcl.CollisionObject(geom, t)
 
         # Collide with manager's objects
         cdata = fcl.CollisionData()
@@ -310,13 +323,13 @@ class CollisionManager(object):
         if return_names or return_data:
             for contact in cdata.result.contacts:
                 cg = contact.o1
-                if cg == b:
+                if cg == geom:
                     cg = contact.o2
                 name = self._extract_name(cg)
 
                 names = (name, '__external')
                 if cg == contact.o2:
-                    names = reversed(names)
+                    names = tuple(reversed(names))
 
                 if return_names:
                     objs_in_collision.add(name)
@@ -442,7 +455,7 @@ class CollisionManager(object):
                     objs_in_collision.add(names)
                 if return_data:
                     if reverse:
-                        names = reversed(names)
+                        names = tuple(reversed(names))
                     contact_data.append(ContactData(names, contact))
 
         if return_names and return_data:
@@ -486,17 +499,21 @@ class CollisionManager(object):
         if transform is None:
             transform = np.eye(4)
 
-        # Create FCL data
-        b = self._get_BVH(mesh)
+        # create BVH/Convex
+        geom = self._get_fcl_obj(mesh)
 
+        # create the FCL transform from (4,4) matrix
         t = fcl.Transform(transform[:3, :3], transform[:3, 3])
-        o = fcl.CollisionObject(b, t)
+        o = fcl.CollisionObject(geom, t)
 
         # Collide with manager's objects
-        ddata = fcl.DistanceData()
+        ddata = fcl.DistanceData(fcl.DistanceRequest(enable_signed_distance=True))
         if return_data:
             ddata = fcl.DistanceData(
-                fcl.DistanceRequest(enable_nearest_points=True),
+                fcl.DistanceRequest(
+                    enable_nearest_points=True,
+                    enable_signed_distance=True
+                ),
                 fcl.DistanceResult()
             )
 
@@ -508,14 +525,14 @@ class CollisionManager(object):
         name, data = None, None
         if return_name or return_data:
             cg = ddata.result.o1
-            if cg == b:
+            if cg == geom:
                 cg = ddata.result.o2
 
             name = self._extract_name(cg)
 
             names = (name, '__external')
             if cg == ddata.result.o2:
-                names = reversed(names)
+                names = tuple(reversed(names))
             data = DistanceData(names, ddata.result)
 
         if return_name and return_data:
@@ -548,10 +565,13 @@ class CollisionManager(object):
         data : DistanceData
           Extra data about the distance query
         """
-        ddata = fcl.DistanceData()
+        ddata = fcl.DistanceData(fcl.DistanceRequest(enable_signed_distance=True))
         if return_data:
             ddata = fcl.DistanceData(
-                fcl.DistanceRequest(enable_nearest_points=True),
+                fcl.DistanceRequest(
+                    enable_nearest_points=True,
+                    enable_signed_distance=True,
+                ),
                 fcl.DistanceResult()
             )
 
@@ -603,10 +623,13 @@ class CollisionManager(object):
         data : DistanceData
           Extra data about the distance query
         """
-        ddata = fcl.DistanceData()
+        ddata = fcl.DistanceData(fcl.DistanceRequest(enable_signed_distance=True))
         if return_data:
             ddata = fcl.DistanceData(
-                fcl.DistanceRequest(enable_nearest_points=True),
+                fcl.DistanceRequest(
+                    enable_nearest_points=True,
+                    enable_signed_distance=True,
+                ),
                 fcl.DistanceResult()
             )
 
@@ -628,7 +651,7 @@ class CollisionManager(object):
 
             dnames = tuple(names)
             if reverse:
-                dnames = reversed(dnames)
+                dnames = tuple(reversed(dnames))
             data = DistanceData(dnames, ddata.result)
 
         if return_names and return_data:
@@ -640,22 +663,26 @@ class CollisionManager(object):
         else:
             return distance
 
-    def _get_BVH(self, mesh):
+    def _get_fcl_obj(self, mesh):
         """
-        Get a BVH for a mesh.
+        Get a BVH or Convex for a mesh.
 
         Parameters
         -------------
         mesh : Trimesh
-          Mesh to create BVH for
+          Mesh to create BVH/Convex for
 
         Returns
         --------------
-        bvh : fcl.BVHModel
-          BVH object of source mesh
+        obj : fcl.BVHModel or fcl.Convex
+          BVH/Convex object of source mesh
         """
-        bvh = mesh_to_BVH(mesh)
-        return bvh
+
+        if mesh.is_convex:
+            obj = mesh_to_convex(mesh)
+        else:
+            obj = mesh_to_BVH(mesh)
+        return obj
 
     def _extract_name(self, geom):
         """
@@ -698,6 +725,25 @@ def mesh_to_BVH(mesh):
     return bvh
 
 
+def mesh_to_convex(mesh):
+    """
+    Create a Convex object from a Trimesh object
+
+    Parameters
+    -----------
+    mesh : Trimesh
+      Input geometry
+
+    Returns
+    ------------
+    convex : fcl.Convex
+      Convex of input geometry
+    """
+    fs = np.concatenate((3 * np.ones((len(mesh.faces), 1), dtype=np.int64), mesh.faces),
+                        axis=1)
+    return fcl.Convex(mesh.vertices, len(fs), fs.flatten())
+
+
 def scene_to_collision(scene):
     """
     Create collision objects from a trimesh.Scene object.
diff --git a/trimesh/convex.py b/trimesh/convex.py
index 8f9d594f..ab1d7396 100644
--- a/trimesh/convex.py
+++ b/trimesh/convex.py
@@ -182,14 +182,24 @@ def is_convex(mesh):
         return False
 
     # don't consider zero- area faces
-    nonzero = mesh.area_faces > tol.merge
+    nonzero = mesh.area_faces > tol.zero
     # adjacencies with two nonzero faces
     adj_ok = nonzero[mesh.face_adjacency].all(axis=1)
+
+    # if none of our face pairs are both nonzero exit
+    # TODO : is this the correct check?
+    # or should we just compare the projections
+    # to the mesh scale
+    if not adj_ok.any():
+        return False
+
     # make threshold of convexity scale- relative
     threshold = tol.planar * mesh.scale
+
     # if projections of vertex onto plane of adjacent
     # face is negative, it means the face pair is locally
     # convex, and if that is true for all faces the mesh is convex
+
     convex = bool(mesh.face_adjacency_projections[adj_ok].max() < threshold)
 
     return convex
diff --git a/trimesh/resources/schema/primitive/box.schema.json b/trimesh/resources/schema/primitive/box.schema.json
index e2cadf86..72f37da9 100644
--- a/trimesh/resources/schema/primitive/box.schema.json
+++ b/trimesh/resources/schema/primitive/box.schema.json
@@ -1,20 +1,26 @@
 {
+  "$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/box.schema.json",
   "$schema": "http://json-schema.org/draft-04/schema#",
-  "type": "object",
+  "additionalProperties": false,
   "description": "A 3D box primitive.",
   "properties": {
-    "transform": { "$ref": "transform.schema.json" },
-    "kind": { "type": "string", "pattern": "(^box$)" },
     "extents": {
-      "type": "array",
+      "description": "The length of each side of the 3D box. The center of mass will be at the origin, and the minimum values will be at (-extents / 2) and the maximum values will be at (extents / 2).",
       "items": {
-        "type": "number",
+        "maxItems": 3,
         "minItems": 3,
-        "maxItems": 3
+        "type": "number"
       },
-      "description": "The length of each side of the 3D box. The center of mass will be at the origin, and the minimum values will be at (-extents / 2) and the maximum values will be at (extents / 2)."
+      "type": "array"
+    },
+    "kind": {
+      "pattern": "(^box$)",
+      "type": "string"
+    },
+    "transform": {
+      "$ref": "transform.schema.json"
     }
   },
-  "additionalProperties": false,
-  "required": ["extents", "kind"]
+  "required": ["extents", "kind"],
+  "type": "object"
 }
diff --git a/trimesh/resources/schema/primitive/capsule.schema.json b/trimesh/resources/schema/primitive/capsule.schema.json
index df967a7e..3a019bc6 100644
--- a/trimesh/resources/schema/primitive/capsule.schema.json
+++ b/trimesh/resources/schema/primitive/capsule.schema.json
@@ -1,18 +1,25 @@
 {
+  "$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/capsule.schema.json",
   "$schema": "http://json-schema.org/draft-04/schema#",
-  "type": "object",
-  "description": "A 3D cylinder primitive.",
+  "additionalProperties": false,
+  "description": "A 3D capsule primitive: a cylinder capped by tangent hemispheres.",
   "properties": {
-    "transform": { "$ref": "transform.schema.json" },
-    "radius": {
-      "type": "number",
-      "description": "The radius of the cylinder."
-    },
     "height": {
-      "type": "number",
-      "description": "The height of the cylinder along Z. The maximum Z value will be at (height / 2) and the minimum will be at (-height / 2)"
+      "description": "The total height of the capsule including caps.",
+      "type": "number"
+    },
+    "kind": {
+      "pattern": "(^capsule$)",
+      "type": "string"
+    },
+    "radius": {
+      "description": "The radius of the cylinder and hemispherical caps.",
+      "type": "number"
     },
-    "kind": { "type": "string", "pattern": "(^capsule$)" }
+    "transform": {
+      "$ref": "transform.schema.json"
+    }
   },
-  "required": ["radius", "height", "kind"]
+  "required": ["radius", "height", "kind"],
+  "type": "object"
 }
diff --git a/trimesh/resources/schema/primitive/cylinder.schema.json b/trimesh/resources/schema/primitive/cylinder.schema.json
index d3b915bf..5eb81d36 100644
--- a/trimesh/resources/schema/primitive/cylinder.schema.json
+++ b/trimesh/resources/schema/primitive/cylinder.schema.json
@@ -1,18 +1,25 @@
 {
+  "$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/cylinder.schema.json",
   "$schema": "http://json-schema.org/draft-04/schema#",
-  "type": "object",
+  "additionalProperties": false,
   "description": "A 3D cylinder primitive.",
   "properties": {
-    "transform": { "$ref": "transform.schema.json" },
-    "radius": {
-      "type": "number",
-      "description": "The radius of the cylinder."
-    },
     "height": {
-      "type": "number",
-      "description": "The height of the cylinder along Z. The maximum Z value will be at (height / 2) and the minimum will be at (-height / 2)"
+      "description": "The height of the cylinder along Z. The maximum Z value will be at (height / 2) and the minimum will be at (-height / 2)",
+      "type": "number"
+    },
+    "kind": {
+      "pattern": "(^cylinder$)",
+      "type": "string"
+    },
+    "radius": {
+      "description": "The radius of the cylinder.",
+      "type": "number"
     },
-    "kind": { "type": "string", "pattern": "(^cylinder$)" }
+    "transform": {
+      "$ref": "transform.schema.json"
+    }
   },
-  "required": ["radius", "height", "kind"]
+  "required": ["radius", "height", "kind"],
+  "type": "object"
 }
diff --git a/trimesh/resources/schema/primitive/extrusion.schema.json b/trimesh/resources/schema/primitive/extrusion.schema.json
index 0ad51caa..1f999744 100644
--- a/trimesh/resources/schema/primitive/extrusion.schema.json
+++ b/trimesh/resources/schema/primitive/extrusion.schema.json
@@ -1,13 +1,23 @@
 {
+  "$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/extrusion.schema.json",
   "$schema": "http://json-schema.org/draft-04/schema#",
-  "type": "object",
-
+  "additionalProperties": false,
+  "description": "A 2D polygon extruded from Z=0 along positive Z by `height` then transformed by the `transform` matrix.",
   "properties": {
-    "transform": { "$ref": "transform.schema.json" },
-    "kind": { "type": "string", "pattern": "(^extrusion$)" },
-    "polygon": { "type": "string" },
-    "height": { "type": "number" }
+    "height": {
+      "type": "number"
+    },
+    "kind": {
+      "pattern": "(^extrusion$)",
+      "type": "string"
+    },
+    "polygon": {
+      "$ref": "wkt.polygon.schema.json"
+    },
+    "transform": {
+      "$ref": "transform.schema.json"
+    }
   },
-  "description": "A 2D polygon extruded along positive Z by `height` then transformed by a matrix",
-  "required": ["kind", "transform", "polygon", "height"]
+  "required": ["kind", "polygon", "height"],
+  "type": "object"
 }
diff --git a/trimesh/resources/schema/primitive/primitive.schema.json b/trimesh/resources/schema/primitive/primitive.schema.json
index db918513..7b3a2d8d 100644
--- a/trimesh/resources/schema/primitive/primitive.schema.json
+++ b/trimesh/resources/schema/primitive/primitive.schema.json
@@ -1,13 +1,22 @@
 {
+  "$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/primitive.schema.json",
   "$schema": "http://json-schema.org/draft-04/schema#",
-
-  "description": "A geometric primitive.",
-
   "anyOf": [
-    { "$ref": "box.schema.json" },
-    { "$ref": "sphere.schema.json" },
-    { "$ref": "extrusion.schema.json" },
-    { "$ref": "capsule.schema.json" },
-    { "$ref": "cylinder.schema.json" }
-  ]
+    {
+      "$ref": "box.schema.json"
+    },
+    {
+      "$ref": "sphere.schema.json"
+    },
+    {
+      "$ref": "capsule.schema.json"
+    },
+    {
+      "$ref": "cylinder.schema.json"
+    },
+    {
+      "$ref": "extrusion.schema.json"
+    }
+  ],
+  "description": "A geometric primitive."
 }
diff --git a/trimesh/resources/schema/primitive/sphere.schema.json b/trimesh/resources/schema/primitive/sphere.schema.json
index d3e6e25b..625b0d5a 100644
--- a/trimesh/resources/schema/primitive/sphere.schema.json
+++ b/trimesh/resources/schema/primitive/sphere.schema.json
@@ -1,12 +1,20 @@
 {
+  "$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/sphere.schema.json",
   "$schema": "http://json-schema.org/draft-04/schema#",
-  "type": "object",
+  "additionalProperties": false,
   "description": "A 3D sphere primitive.",
   "properties": {
-    "transform": { "$ref": "transform.schema.json" },
-    "kind": { "type": "string", "pattern": "(^sphere$)" },
-    "radius": { "type": "number" }
+    "kind": {
+      "pattern": "(^sphere$)",
+      "type": "string"
+    },
+    "radius": {
+      "type": "number"
+    },
+    "transform": {
+      "$ref": "transform.schema.json"
+    }
   },
-  "additionalProperties": false,
-  "required": ["radius", "kind"]
+  "required": ["radius", "kind"],
+  "type": "object"
 }
diff --git a/trimesh/resources/schema/primitive/transform.schema.json b/trimesh/resources/schema/primitive/transform.schema.json
index 7c1317f8..cf949054 100644
--- a/trimesh/resources/schema/primitive/transform.schema.json
+++ b/trimesh/resources/schema/primitive/transform.schema.json
@@ -1,12 +1,16 @@
 {
+  "$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/transform.schema.json",
   "$schema": "http://json-schema.org/draft-04/schema#",
-  "type": "array",
+  "description": "A homogenous transformation matrix. If not passed it is assumed to be an identity matrix ",
   "items": {
-    "type": "array",
-    "minItems": 4,
+    "items": {
+      "maxItems": 4,
+      "minItems": 4,
+      "type": "number"
+    },
     "maxItems": 4,
-    "items": { "type": "number", "minItems": 4, "maxItems": 4 }
+    "minItems": 4,
+    "type": "array"
   },
-
-  "description": "The homogenous transformation matrix applied to this primitive."
+  "type": "array"
 }
diff --git a/trimesh/resources/schema/primitive/trimesh.schema.json b/trimesh/resources/schema/primitive/trimesh.schema.json
index fe31dce0..c22ee16b 100644
--- a/trimesh/resources/schema/primitive/trimesh.schema.json
+++ b/trimesh/resources/schema/primitive/trimesh.schema.json
@@ -1,24 +1,36 @@
 {
+  "$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/trimesh.schema.json",
   "$schema": "http://json-schema.org/draft-04/schema#",
-  "type": "object",
+  "additionalProperties": false,
   "description": "A 3D triangular mesh in the simplest possible JSON format.",
   "properties": {
-    "kind": { "type": "string", "pattern": "(^trimesh$)" },
-    "vertices": {
-      "type": "array",
+    "faces": {
       "items": {
-        "type": "array",
-        "items": { "type": "number", "minItems": 3, "maxItems": 3 }
-      }
+        "items": {
+          "maxItems": 3,
+          "minItems": 3,
+          "type": "integer"
+        },
+        "type": "array"
+      },
+      "type": "array"
     },
-    "faces": {
-      "type": "array",
+    "kind": {
+      "pattern": "(^trimesh$)",
+      "type": "string"
+    },
+    "vertices": {
       "items": {
-        "type": "array",
-        "items": { "type": "integer", "minItems": 3, "maxItems": 3 }
-      }
+        "items": {
+          "maxItems": 3,
+          "minItems": 3,
+          "type": "number"
+        },
+        "type": "array"
+      },
+      "type": "array"
     }
   },
-  "additionalProperties": false,
-  "required": ["vertices", "faces"]
+  "required": ["vertices", "faces"],
+  "type": "object"
 }
diff --git a/trimesh/resources/schema/primitive/wkt.polygon.schema.json b/trimesh/resources/schema/primitive/wkt.polygon.schema.json
new file mode 100644
index 00000000..74022cfa
--- /dev/null
+++ b/trimesh/resources/schema/primitive/wkt.polygon.schema.json
@@ -0,0 +1,7 @@
+{
+  "$id": "https://github.com/mikedh/trimesh/blob/main/trimesh/resources/schema/primitive/wkt.polygon.schema.json",
+  "$schema": "http://json-schema.org/draft-04/schema#",
+  "description": "A single polygon in the Well Known Text format (WKT).",
+  "pattern": " *POLYGON *\\( *\\( *([+-]?([0-9]*[.])?[0-9]+ *[+-]?([0-9]*[.])?[0-9]+ *,* *)*\\) *\\) *",
+  "type": "string"
+}
diff --git a/trimesh/version.py b/trimesh/version.py
index 6815e93d..1ed6cf5d 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.12.7'
+__version__ = '3.12.8'
diff --git a/trimesh/visual/color.py b/trimesh/visual/color.py
index 3e595808..5c645522 100644
--- a/trimesh/visual/color.py
+++ b/trimesh/visual/color.py
@@ -161,7 +161,12 @@ class ColorVisuals(Visuals):
           Contains the same information as self
         """
         copied = ColorVisuals()
+        # call the literally insane generators
+        self.face_colors
+        self.vertex_colors
+        # copy anything that's actually data
         copied._data.data = copy.deepcopy(self._data.data)
+
         return copied
 
     @property
@@ -173,7 +178,8 @@ class ColorVisuals(Visuals):
 
         Returns
         ----------
-        colors: (len(mesh.faces), 4) uint8, RGBA color for each face
+        colors : (len(mesh.faces), 4) uint8
+          RGBA color for each face
         """
         return self._get_colors(name='face')
 
@@ -187,10 +193,10 @@ class ColorVisuals(Visuals):
 
         Parameters
         ------------
-        colors: (len(mesh.faces), 3), set each face to the specified color
-                (len(mesh.faces), 4), set each face to the specified color
-                (3,) int, set the whole mesh this color
-                (4,) int, set the whole mesh this color
+        colors : (len(mesh.faces), 3), set each face to the specified color
+                 (len(mesh.faces), 4), set each face to the specified color
+                 (3,) int, set the whole mesh this color
+                 (4,) int, set the whole mesh this color
         """
         if values is None:
             if 'face_colors' in self._data:
@@ -230,10 +236,10 @@ class ColorVisuals(Visuals):
 
         Parameters
         ------------
-        colors: (len(mesh.vertices), 3), set each face to the color
-                (len(mesh.vertices), 4), set each face to the color
-                (3,) int, set the whole mesh this color
-                (4,) int, set the whole mesh this color
+        colors : (len(mesh.vertices), 3), set each face to the color
+                 (len(mesh.vertices), 4), set each face to the color
+                 (3,) int, set the whole mesh this color
+                 (4,) int, set the whole mesh this color
         """
         if values is None:
             if 'vertex_colors' in self._data:
@@ -261,8 +267,7 @@ class ColorVisuals(Visuals):
         self._data['vertex_colors'] = colors
         self._cache.verify()
 
-    def _get_colors(self,
-                    name):
+    def _get_colors(self, name):
         """
         A magical function which maintains the sanity of vertex and face colors.
 
@@ -278,11 +283,13 @@ class ColorVisuals(Visuals):
 
         Parameters
         -----------
-        name: str, 'face', or 'vertex'
+        name : str
+          Values 'face' or 'vertex'
 
         Returns
         -----------
-        colors: (count, 4) uint8, RGBA colors
+        colors : (count, 4) uint8
+          RGBA colors
         """
 
         count = None


Test patch:
diff --git a/tests/test_collision.py b/tests/test_collision.py
index 14edb25e..9eee5055 100644
--- a/tests/test_collision.py
+++ b/tests/test_collision.py
@@ -3,15 +3,18 @@ try:
 except BaseException:
     import generic as g
 
+try:
+    import fcl
+except BaseException:
+    fcl = None
+
 
 class CollisionTest(g.unittest.TestCase):
 
     def test_collision(self):
         # Ensure that FCL is importable
-        try:
-            g.trimesh.collision.CollisionManager()
-        except ValueError:
-            g.log.warning('skipping collision tests, no FCL installed')
+        if fcl is None:
+            g.log.warning('skipping FCL tests: not installed')
             return
 
         cube = g.get_mesh('unit_cube.STL')
@@ -30,14 +33,17 @@ class CollisionTest(g.unittest.TestCase):
         ret = m.in_collision_single(cube)
         assert ret is True
 
-        ret, names, data = m.in_collision_single(cube,
-                                                 tf1,
-                                                 return_names=True,
-                                                 return_data=True)
+        ret, names, data = m.in_collision_single(
+            cube,
+            tf1,
+            return_names=True,
+            return_data=True)
 
         assert ret is True
-        assert all(len(i.point) == 3 for i in data)
-        assert (int(i.depth) for i in data)
+        for c in data:
+            assert g.np.allclose(c.point, g.np.array([5.0, -0.5, 0.5]))
+            assert g.np.isclose(c.depth, 1.0)
+            assert g.np.allclose(c.normal, g.np.array([-1.0, 0.0, 0.0]))
 
         if 'cube1' not in names:
             print('\n\n', m._objs.keys(), names)
@@ -96,12 +102,26 @@ class CollisionTest(g.unittest.TestCase):
         assert ('cube1', 'cube3') in names
         assert ('cube3', 'cube1') not in names
 
+    def test_random_spheres(self):
+        if fcl is None:
+            g.log.warning('skipping FCL tests: not installed')
+            return
+
+        # check to see if a scene with a bunch of random
+        # spheres
+        spheres = [g.trimesh.creation.icosphere(
+            radius=i[0]).apply_translation(
+                i[1:] * 100) for i in
+            g.random((1000, 4))]
+        scene = g.trimesh.Scene(spheres)
+        manager, _ = g.trimesh.collision.scene_to_collision(
+            scene)
+        collides = manager.in_collision_internal()
+        assert isinstance(collides, bool)
+
     def test_distance(self):
-        # Ensure that FCL is importable
-        try:
-            g.trimesh.collision.CollisionManager()
-        except ValueError:
-            g.log.warning('skipping collision tests, no FCL installed')
+        if fcl is None:
+            g.log.warning('skipping FCL tests: not installed')
             return
 
         cube = g.get_mesh('unit_cube.STL')
@@ -118,6 +138,9 @@ class CollisionTest(g.unittest.TestCase):
         tf4 = g.np.eye(4)
         tf4[:3, 3] = g.np.array([-2, 0, 0])
 
+        tf5 = g.np.eye(4)
+        tf5[:3, 3] = g.np.array([5.75, 0, 0])
+
         # Test one-to-many distance checking
         m = g.trimesh.collision.CollisionManager()
         m.add_object('cube1', cube, tf1)
@@ -125,6 +148,9 @@ class CollisionTest(g.unittest.TestCase):
         dist = m.min_distance_single(cube)
         assert g.np.isclose(dist, 4.0)
 
+        dist = m.min_distance_single(cube, tf5)
+        assert g.np.isclose(dist, -0.25)
+
         dist, name = m.min_distance_single(cube, return_name=True)
         assert g.np.isclose(dist, 4.0)
         assert name == 'cube1'
@@ -166,28 +192,34 @@ class CollisionTest(g.unittest.TestCase):
         m.add_object('cube1', cube, tf1)
 
         n = g.trimesh.collision.CollisionManager()
-        n.add_object('cube0', cube, tf2)
+        n.add_object('cube4', cube, tf2)
 
-        dist, names = m.min_distance_other(n, return_names=True)
+        dist, names, data = m.min_distance_other(n, return_names=True, return_data=True)
         assert g.np.isclose(dist, 4.0)
-        assert names == ('cube0', 'cube0')
+        assert names == ('cube0', 'cube4')
+        assert g.np.isclose(
+            g.np.linalg.norm(data.point(names[0]) - data.point(names[1])),
+            dist
+        )
 
-        n.add_object('cube4', cube, tf4)
+        n.add_object('cube5', cube, tf4)
 
-        dist, names = m.min_distance_other(n, return_names=True)
+        dist, names, data = m.min_distance_other(n, return_names=True, return_data=True)
         assert g.np.isclose(dist, 1.0)
-        assert names == ('cube0', 'cube4')
+        assert names == ('cube0', 'cube5')
+        assert g.np.isclose(
+            g.np.linalg.norm(data.point(names[0]) - data.point(names[1])),
+            dist
+        )
 
     def test_scene(self):
-        try:
-            import fcl  # NOQA
-        except ImportError:
+        if fcl is None:
             return
         scene = g.get_mesh('cycloidal.3DXML')
-
         manager, objects = g.trimesh.collision.scene_to_collision(scene)
 
         assert manager.in_collision_internal()
+        assert objects is not None
 
 
 if __name__ == '__main__':
diff --git a/tests/test_color.py b/tests/test_color.py
index 0058fae3..de1dc33a 100644
--- a/tests/test_color.py
+++ b/tests/test_color.py
@@ -255,6 +255,19 @@ class VisualTest(g.unittest.TestCase):
 
         assert g.np.allclose(m.visual.face_colors, color)
 
+    def test_copy(self):
+        s = g.trimesh.creation.uv_sphere().scene()
+        s.geometry['geometry_0'].visual.face_colors[:, :3] = [0, 255, 0]
+
+        a = s.geometry['geometry_0']
+        assert id(a) == id(s.geometry['geometry_0'])
+
+        b = s.geometry['geometry_0'].copy()
+        assert id(a) != id(b)
+
+        assert g.np.allclose(a.visual.face_colors,
+                             b.visual.face_colors)
+
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_convex.py b/tests/test_convex.py
index b294f955..f7e75be3 100644
--- a/tests/test_convex.py
+++ b/tests/test_convex.py
@@ -7,14 +7,25 @@ except BaseException:
 class ConvexTest(g.unittest.TestCase):
 
     def test_convex(self):
-        for name in ['featuretype.STL',
-                     'quadknot.obj',
-                     'unit_cube.STL',
-                     '1002_tray_bottom.STL']:
 
-            mesh = g.get_mesh(name)
+        # store (true is_convex, mesh) tuples
+        meshes = [(False, g.get_mesh('featuretype.STL')),
+                  (False, g.get_mesh('quadknot.obj')),
+                  (True, g.get_mesh('unit_cube.STL')),
+                  (False, g.get_mesh('1002_tray_bottom.STL')),
+                  (True, g.trimesh.creation.icosphere()),
+                  (True, g.trimesh.creation.uv_sphere()),
+                  (True, g.trimesh.creation.box()),
+                  (True, g.trimesh.creation.cylinder(
+                      radius=1, height=10)),
+                  (True, g.trimesh.creation.capsule())]
+
+        for is_convex, mesh in meshes:
             assert mesh.is_watertight
 
+            # mesh should match it's true convexity
+            assert mesh.is_convex == is_convex
+
             hulls = []
             volume = []
 
diff --git a/tests/test_primitives.py b/tests/test_primitives.py
index 540b665a..41bdc304 100644
--- a/tests/test_primitives.py
+++ b/tests/test_primitives.py
@@ -80,7 +80,6 @@ class PrimitiveTest(g.unittest.TestCase):
 
             # convert to a dict
             d = primitive.to_dict()
-
             # validate the output of the to-dict method
             g.jsonschema.validate(d, schema)