Repository: mikedh/trimesh (language: python)

Issue:
Pass resolver to _read_buffers() when loading a .GLB file
At about line 444 in gltf.py the resolver argument should be passed to the _read_buffers() function. That is what is currently
    kwargs = _read_buffers(
        header=header,
        buffers=buffers,
        ignore_broken=ignore_broken,
        merge_primitives=merge_primitives,
        skip_materials=skip_materials,
        mesh_kwargs=mesh_kwargs
    )
should instead read
    kwargs = _read_buffers(
        header=header,
        buffers=buffers,
        ignore_broken=ignore_broken,
        merge_primitives=merge_primitives,
        skip_materials=skip_materials,
        mesh_kwargs=mesh_kwargs,
        resolver=resolver
    )

If this is not done, the texture image can not be loaded from a file where the texture URI is not base64 encoded.
Bump jinja2 from 3.1.3 to 3.1.4 in /docs
Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.3 to 3.1.4.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/pallets/jinja/releases">jinja2's releases</a>.</em></p>
<blockquote>
<h2>3.1.4</h2>
<p>This is the Jinja 3.1.4 security release, which fixes security issues and bugs but does not otherwise change behavior and should not result in breaking changes.</p>
<p>PyPI: <a href="https://pypi.org/project/Jinja2/3.1.4/">https://pypi.org/project/Jinja2/3.1.4/</a>
Changes: <a href="https://jinja.palletsprojects.com/en/3.1.x/changes/#version-3-1-4">https://jinja.palletsprojects.com/en/3.1.x/changes/#version-3-1-4</a></p>
<ul>
<li>The <code>xmlattr</code> filter does not allow keys with <code>/</code> solidus, <code>&gt;</code> greater-than sign, or <code>=</code> equals sign, in addition to disallowing spaces. Regardless of any validation done by Jinja, user input should never be used as keys to this filter, or must be separately validated first. GHSA-h75v-3vvj-5mfj</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a href="https://github.com/pallets/jinja/blob/main/CHANGES.rst">jinja2's changelog</a>.</em></p>
<blockquote>
<h2>Version 3.1.4</h2>
<p>Released 2024-05-05</p>
<ul>
<li>The <code>xmlattr</code> filter does not allow keys with <code>/</code> solidus, <code>&gt;</code>
greater-than sign, or <code>=</code> equals sign, in addition to disallowing spaces.
Regardless of any validation done by Jinja, user input should never be used
as keys to this filter, or must be separately validated first.
:ghsa:<code>h75v-3vvj-5mfj</code></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/pallets/jinja/commit/dd4a8b5466d8790540c181590b14db4d4d889d57"><code>dd4a8b5</code></a> release version 3.1.4</li>
<li><a href="https://github.com/pallets/jinja/commit/0668239dc6b44ef38e7a6c9f91f312fd4ca581cb"><code>0668239</code></a> Merge pull request from GHSA-h75v-3vvj-5mfj</li>
<li><a href="https://github.com/pallets/jinja/commit/d655030770081e2dfe46f90e27620472a502289d"><code>d655030</code></a> disallow invalid characters in keys to xmlattr filter</li>
<li><a href="https://github.com/pallets/jinja/commit/a7863ba9d3521f1450f821119c50d19d7ecea329"><code>a7863ba</code></a> add ghsa links</li>
<li><a href="https://github.com/pallets/jinja/commit/b5c98e78c2ee7d2bf0aa06d29ed9bf7082de9cf4"><code>b5c98e7</code></a> start version 3.1.4</li>
<li><a href="https://github.com/pallets/jinja/commit/da3a9f0b804199845fcb76f2e08748bdaeba93ee"><code>da3a9f0</code></a> update project files (<a href="https://redirect.github.com/pallets/jinja/issues/1968">#1968</a>)</li>
<li><a href="https://github.com/pallets/jinja/commit/0ee5eb41d1a2d7d9a05a02dc26dd70e63aaaeeb1"><code>0ee5eb4</code></a> satisfy formatter, linter, and strict mypy</li>
<li><a href="https://github.com/pallets/jinja/commit/20477c63575175196bfc8103f223cc9f5642595d"><code>20477c6</code></a> update project files (<a href="https://redirect.github.com/pallets/jinja/issues/5457">#5457</a>)</li>
<li><a href="https://github.com/pallets/jinja/commit/e491223739dedbb1f4fc6a71340c1484e149d947"><code>e491223</code></a> update pyyaml dev dependency</li>
<li><a href="https://github.com/pallets/jinja/commit/36f98854c721f98ba103f97f65a8a098da5af0d7"><code>36f9885</code></a> fix pr link</li>
<li>Additional commits viewable in <a href="https://github.com/pallets/jinja/compare/3.1.3...3.1.4">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=jinja2&package-manager=pip&previous-version=3.1.3&new-version=3.1.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/mikedh/trimesh/network/alerts).

</details>
Loading GLB (GLTF) faces index out of bounds error
This change (https://github.com/mikedh/trimesh/pull/1729/commits/e21dd4227ba808d0eebd3d9d8ce8e5445cce6ad8#diff-35fbb441527c263b3e4f34ecf7fce9e380a6e7aeb0e5b3bce1baa1d9301aa898R1413) is causing error when loading GLTF generated by https://www.npmjs.com/package/@gltf-transform/core.

Old (working code) (trimesh/exchange/gltf.py line 1402):

```python
kwargs['faces'] = np.arange(len(kwargs['vertices']), dtype=np.int64).reshape((-1, 3))
```

New code introduced by [commit](https://github.com/mikedh/trimesh/pull/1729/commits/e21dd4227ba808d0eebd3d9d8ce8e5445cce6ad8).

```python
kwargs['faces'] = np.arange(len(kwargs['vertices']) * 3, dtype=np.int64).reshape((-1, 3))
```
_(notice `* 3`)_

Examples of where the error occurs:

![Screenshot 2024-05-06 at 12 41 29](https://github.com/mikedh/trimesh/assets/1789894/53cf4d13-6fea-4377-92e0-39d2680fa84b)
![Screenshot 2024-05-06 at 12 42 45](https://github.com/mikedh/trimesh/assets/1789894/6cbdb4cd-093d-4027-94e7-33234e834df2)

---

When the gltf has no indices we want to interpret groups of 3 consecutive vertices as triangles.
The values in the faces array should not exceed the length of the vertices array.
Setting faces to
```python
kwargs['faces'] = np.arange(len(kwargs['vertices']), dtype=np.int64).reshape((-1, 3))
```
should give the expected triangles consisting of every 3 consecutive vertices in the vertices array.


Was the change `* 3` in the referenced commit intentional, and if so I was unable to find a good reasoning. If it was not intentional could we remove the `* 3` 🙏 ?



Discussion / hints:

Fix patch:
diff --git a/Dockerfile b/Dockerfile
index fe40e20c0..cb9cc3684 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -29,7 +29,13 @@ USER user
 # install trimesh into .local
 # then delete any included test directories
 # and remove Cython after all the building is complete
+
+
+# TODO
+# remove mapbox-earcut fork when this is merged:
+# https://github.com/skogler/mapbox_earcut_python/pull/15
 RUN pip install --user /home/user[easy] && \
+    pip install --user --force-reinstall git+https://github.com/mikedh/mapbox_earcut_python.git && \
     find /home/user/.local -type d -name tests -prune -exec rm -rf {} \;
 
 ####################################
@@ -67,7 +73,9 @@ RUN trimesh-setup --install=test,gmsh,gltf_validator,llvmpipe,binvox
 USER user
 
 # install things like pytest
-RUN pip install -e .[all]
+# install prerelease for tests and make sure we're on Numpy 2.X
+RUN pip install --pre --upgrade .[all] && \
+    python -c "import numpy as n; assert(n.__version__.startswith('1'))"
 
 # check for lint problems
 RUN ruff check trimesh
diff --git a/docker/trimesh-setup b/docker/trimesh-setup
index e0d8fa171..c8eb461ea 100755
--- a/docker/trimesh-setup
+++ b/docker/trimesh-setup
@@ -27,7 +27,8 @@ config_json = """
     "build": [
       "build-essential",
       "g++",
-      "make"
+      "make",
+      "git"
     ],
     "docs": [
       "make",
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 53e871e52..8362dfcaa 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -3,11 +3,11 @@ recommonmark==0.7.1
 jupyter==1.0.0
 
 # get sphinx version range from furo install
-furo==2024.1.29
-myst-parser==2.0.0
+furo==2024.5.6
+myst-parser==3.0.1
 pyopenssl==24.1.0
 autodocsumm==0.2.12
-jinja2==3.1.3
-matplotlib==3.8.3
-nbconvert==7.16.2
+jinja2==3.1.4
+matplotlib==3.8.4
+nbconvert==7.16.4
 
diff --git a/examples/raytrace.py b/examples/raytrace.py
index 582429561..c8a23f704 100644
--- a/examples/raytrace.py
+++ b/examples/raytrace.py
@@ -45,7 +45,7 @@
     a = np.zeros(scene.camera.resolution, dtype=np.uint8)
 
     # scale depth against range (0.0 - 1.0)
-    depth_float = (depth - depth.min()) / depth.ptp()
+    depth_float = (depth - depth.min()) / np.ptp(depth)
 
     # convert depth into 0 - 255 uint8
     depth_int = (depth_float * 255).round().astype(np.uint8)
diff --git a/models/Mesh_PrimitiveMode_04.bin b/models/Mesh_PrimitiveMode_04.bin
new file mode 100644
index 000000000..a2e5d56a2
Binary files /dev/null and b/models/Mesh_PrimitiveMode_04.bin differ
diff --git a/models/Mesh_PrimitiveMode_04.gltf b/models/Mesh_PrimitiveMode_04.gltf
new file mode 100644
index 000000000..7a1b67ddf
--- /dev/null
+++ b/models/Mesh_PrimitiveMode_04.gltf
@@ -0,0 +1,71 @@
+{
+  "accessors": [
+    {
+      "bufferView": 0,
+      "componentType": 5126,
+      "count": 4,
+      "type": "VEC3",
+      "max": [
+        0.5,
+        0.5,
+        0.0
+      ],
+      "min": [
+        -0.5,
+        -0.5,
+        0.0
+      ],
+      "name": "Positions Accessor"
+    }
+  ],
+  "asset": {
+    "generator": "glTF Asset Generator",
+    "version": "2.0"
+  },
+  "buffers": [
+    {
+      "uri": "Mesh_PrimitiveMode_04.bin",
+      "byteLength": 48
+    }
+  ],
+  "bufferViews": [
+    {
+      "buffer": 0,
+      "byteLength": 48,
+      "name": "Positions"
+    }
+  ],
+  "materials": [
+    {
+      "pbrMetallicRoughness": {
+        "metallicFactor": 0.0
+      }
+    }
+  ],
+  "meshes": [
+    {
+      "primitives": [
+        {
+          "attributes": {
+            "POSITION": 0
+          },
+          "material": 0,
+          "mode": 5
+        }
+      ]
+    }
+  ],
+  "nodes": [
+    {
+      "mesh": 0
+    }
+  ],
+  "scene": 0,
+  "scenes": [
+    {
+      "nodes": [
+        0
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/models/no_indices_3storybuilding.glb b/models/no_indices_3storybuilding.glb
new file mode 100644
index 000000000..058dceb97
Binary files /dev/null and b/models/no_indices_3storybuilding.glb differ
diff --git a/pyproject.toml b/pyproject.toml
index a219cf6fd..45bc85bff 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ requires = ["setuptools >= 61.0", "wheel"]
 [project]
 name = "trimesh"
 requires-python = ">=3.7"
-version = "4.3.2"
+version = "4.4.0"
 authors = [{name = "Michael Dawson-Haggerty", email = "mikedh@kerfed.com"}]
 license = {file = "LICENSE.md"}
 description = "Import, export, process, analyze and view triangular meshes."
@@ -102,18 +102,20 @@ test = [
     "coveralls",
     "pyright",
     "ezdxf",
-    "gmsh>=4.12.1",
     "pytest",
-    "pymeshlab",
+    #"pymeshlab",
     "pyinstrument",
     "matplotlib",
     "ruff",
     "pytest-beartype; python_version>='3.10'"
 ]
 
+# interfaces.gmsh will be dropped Jan 2025
+deprecated = ["gmsh==4.12.2"]
+
 # requires pip >= 21.2
 # https://hynek.me/articles/python-recursive-optional-dependencies/
-all = ["trimesh[easy,recommend,test]"]
+all = ["trimesh[easy,recommend,test,deprecated]"]
 
 [tool.ruff]
 target-version = "py37"
diff --git a/trimesh/base.py b/trimesh/base.py
index dd7cb4ed1..0126267af 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -412,7 +412,7 @@ def face_normals(self, values: Optional[ArrayLike]) -> None:
             return
         # check if any values are larger than tol.merge
         # don't set the normals if they are all zero
-        ptp = values.ptp()
+        ptp = np.ptp(values)
         if not np.isfinite(ptp):
             log.debug("face_normals contain NaN, ignoring!")
             return
@@ -503,7 +503,7 @@ def vertex_normals(self, values: ArrayLike) -> None:
             values = np.asanyarray(values, order="C", dtype=float64)
             if values.shape == self.vertices.shape:
                 # check to see if they assigned all zeros
-                if values.ptp() < tol.merge:
+                if np.ptp(values) < tol.merge:
                     log.debug("vertex_normals are all zero!")
                 self._cache["vertex_normals"] = values
 
@@ -560,7 +560,7 @@ def extents(self) -> Optional[NDArray[float64]]:
         # if mesh is empty return None
         if self.bounds is None:
             return None
-        extents = self.bounds.ptp(axis=0)
+        extents = np.ptp(self.bounds, axis=0)
 
         return extents
 
diff --git a/trimesh/bounds.py b/trimesh/bounds.py
index 9dfd38e46..b86a9c249 100644
--- a/trimesh/bounds.py
+++ b/trimesh/bounds.py
@@ -272,7 +272,7 @@ def oriented_bounds_coplanar(points):
         area = ((x.max(axis=1) - x.min(axis=1)) * (y.max(axis=1) - y.min(axis=1))).min()
 
         # the volume is 2D area plus the projected height
-        volume = area * projected[:, 2].ptp()
+        volume = area * np.ptp(projected[:, 2])
 
         # store this transform if it's better than one we've seen
         if volume < min_volume:
@@ -283,7 +283,7 @@ def oriented_bounds_coplanar(points):
     # part so now we need to do the bookkeeping to find the box
     vert_ones = np.column_stack((vertices, np.ones(len(vertices)))).T
     projected = np.dot(min_2D, vert_ones).T[:, :3]
-    height = projected[:, 2].ptp()
+    height = np.ptp(projected[:, 2])
     rotation_2D, box = oriented_bounds_2D(projected[:, :2])
     min_extents = np.append(box, height)
     rotation_2D[:2, 2] = 0.0
@@ -294,7 +294,7 @@ def oriented_bounds_coplanar(points):
 
     # transform points using our matrix to find the translation
     transformed = transformations.transform_points(vertices, to_origin)
-    box_center = transformed.min(axis=0) + transformed.ptp(axis=0) * 0.5
+    box_center = transformed.min(axis=0) + np.ptp(transformed, axis=0) * 0.5
     to_origin[:3, 3] = -box_center
 
     # return ordered 3D extents
@@ -374,7 +374,7 @@ def volume_from_angles(spherical, return_data=False):
         """
         to_2D = transformations.spherical_matrix(*spherical, axes="rxyz")
         projected = transformations.transform_points(hull, matrix=to_2D)
-        height = projected[:, 2].ptp()
+        height = np.ptp(projected[:, 2])
 
         try:
             center_2D, radius = nsphere.minimum_nsphere(projected[:, :2])
@@ -405,7 +405,7 @@ def volume_from_angles(spherical, return_data=False):
         # transform vertices to plane to check
         on_plane = transformations.transform_points(obj.vertices, to_2D)
         # cylinder height is overall Z span
-        height = on_plane[:, 2].ptp()
+        height = np.ptp(on_plane[:, 2])
         # center mass is correct on plane, but position
         # along symmetry axis may be wrong so slide it
         slide = transformations.translation_matrix(
@@ -481,7 +481,7 @@ def to_extents(bounds):
     if bounds.shape != (2, 3):
         raise ValueError("bounds must be (2, 3)")
 
-    extents = bounds.ptp(axis=0)
+    extents = np.ptp(bounds, axis=0)
     transform = np.eye(4)
     transform[:3, 3] = bounds.mean(axis=0)
 
diff --git a/trimesh/caching.py b/trimesh/caching.py
index 05aea98fc..36c6fc5dd 100644
--- a/trimesh/caching.py
+++ b/trimesh/caching.py
@@ -186,13 +186,13 @@ def __array_finalize__(self, obj):
         if isinstance(obj, type(self)):
             obj._dirty_hash = True
 
-    def __array_wrap__(self, out_arr, context=None):
+    def __array_wrap__(self, out_arr, context=None, *args, **kwargs):
         """
         Return a numpy scalar if array is 0d.
         See https://github.com/numpy/numpy/issues/5819
         """
         if out_arr.ndim:
-            return np.ndarray.__array_wrap__(self, out_arr, context)
+            return np.ndarray.__array_wrap__(self, out_arr, context, *args, **kwargs)
         # Match numpy's behavior and return a numpy dtype scalar
         return out_arr[()]
 
diff --git a/trimesh/constants.py b/trimesh/constants.py
index 28d4cdcf1..3c79982f2 100644
--- a/trimesh/constants.py
+++ b/trimesh/constants.py
@@ -2,7 +2,7 @@
 
 import numpy as np
 
-from .util import log, now
+from .util import decimal_to_digits, log, now
 
 
 @dataclass
@@ -88,6 +88,7 @@ class TolerancePath:
 
     zero: float = 1e-12
     merge: float = 1e-5
+
     planar: float = 1e-5
     seg_frac: float = 0.125
     seg_angle: float = float(np.radians(50))
@@ -98,9 +99,12 @@ class TolerancePath:
     radius_min: float = 1e-4
     radius_max: float = 50.0
     tangent: float = float(np.radians(20))
-
     strict: bool = False
 
+    @property
+    def merge_digits(self) -> int:
+        return decimal_to_digits(self.merge)
+
 
 @dataclass
 class ResolutionPath:
diff --git a/trimesh/creation.py b/trimesh/creation.py
index 0ed4a7930..21b8fa70f 100644
--- a/trimesh/creation.py
+++ b/trimesh/creation.py
@@ -714,7 +714,7 @@ def box(
         bounds = np.array(bounds, dtype=np.float64)
         if bounds.shape != (2, 3):
             raise ValueError("`bounds` must be (2, 3) float!")
-        extents = bounds.ptp(axis=0)
+        extents = np.ptp(bounds, axis=0)
         vertices *= extents
         vertices += bounds[0]
     elif extents is not None:
diff --git a/trimesh/exchange/gltf.py b/trimesh/exchange/gltf.py
index e48b0696e..7ba1cf331 100644
--- a/trimesh/exchange/gltf.py
+++ b/trimesh/exchange/gltf.py
@@ -15,8 +15,10 @@
 from .. import rendering, resources, transformations, util, visual
 from ..caching import hash_fast
 from ..constants import log, tol
-from ..typed import NDArray, Optional
-from ..util import unique_name
+from ..resolvers import Resolver, ZipResolver
+from ..scene.cameras import Camera
+from ..typed import Mapping, NDArray, Optional, Stream, Union
+from ..util import triangle_strips_to_faces, unique_name
 from ..visual.gloss import specular_to_pbr
 
 # magic numbers which have meaning in GLTF
@@ -48,6 +50,9 @@
     }
 }
 
+# we can accept dict resolvers
+ResolverLike = Union[Resolver, Mapping]
+
 # GL geometry modes
 _GL_LINES = 1
 _GL_POINTS = 0
@@ -261,11 +266,11 @@ def export_glb(
 
 
 def load_gltf(
-    file_obj=None,
-    resolver=None,
-    ignore_broken=False,
-    merge_primitives=False,
-    skip_materials=False,
+    file_obj: Optional[Stream] = None,
+    resolver: Optional[ResolverLike] = None,
+    ignore_broken: bool = False,
+    merge_primitives: bool = False,
+    skip_materials: bool = False,
     **mesh_kwargs,
 ):
     """
@@ -336,11 +341,11 @@ def load_gltf(
 
 
 def load_glb(
-    file_obj,
-    resolver=None,
-    ignore_broken=False,
-    merge_primitives=False,
-    skip_materials=False,
+    file_obj: Stream,
+    resolver: Optional[ResolverLike] = None,
+    ignore_broken: bool = False,
+    merge_primitives: bool = False,
+    skip_materials: bool = False,
     **mesh_kwargs,
 ):
     """
@@ -444,6 +449,7 @@ def load_glb(
         merge_primitives=merge_primitives,
         skip_materials=skip_materials,
         mesh_kwargs=mesh_kwargs,
+        resolver=resolver,
     )
 
     return kwargs
@@ -1350,10 +1356,10 @@ def _read_buffers(
     header,
     buffers,
     mesh_kwargs,
-    ignore_broken=False,
-    merge_primitives=False,
-    skip_materials=False,
-    resolver=None,
+    resolver: Optional[ResolverLike],
+    ignore_broken: bool = False,
+    merge_primitives: bool = False,
+    skip_materials: bool = False,
 ):
     """
     Given binary data and a layout return the
@@ -1511,15 +1517,21 @@ def _read_buffers(
                         if mode == _GL_STRIP:
                             # this is triangle strips
                             flat = access[p["indices"]].reshape(-1)
-                            kwargs["faces"] = util.triangle_strips_to_faces([flat])
+                            kwargs["faces"] = triangle_strips_to_faces([flat])
                         else:
                             kwargs["faces"] = access[p["indices"]].reshape((-1, 3))
                     else:
                         # indices are apparently optional and we are supposed to
                         # do the same thing as webGL drawArrays?
-                        kwargs["faces"] = np.arange(
-                            len(kwargs["vertices"]) * 3, dtype=np.int64
-                        ).reshape((-1, 3))
+                        if mode == _GL_STRIP:
+                            kwargs["faces"] = triangle_strips_to_faces(
+                                np.array([np.arange(len(kwargs["vertices"]))])
+                            )
+                        else:
+                            # GL_TRIANGLES
+                            kwargs["faces"] = np.arange(
+                                len(kwargs["vertices"]), dtype=np.int64
+                            ).reshape((-1, 3))
 
                     if "NORMAL" in attr:
                         # vertex normals are specified
@@ -1664,6 +1676,10 @@ def _read_buffers(
     # unvisited, pairs of node indexes
     queue = deque()
 
+    # camera(s), if they exist
+    camera = None
+    camera_transform = None
+
     if "scene" in header:
         # specify the index of scenes if specified
         scene_index = header["scene"]
@@ -1735,6 +1751,20 @@ def _read_buffers(
                 kwargs["matrix"], np.diag(np.concatenate((child["scale"], [1.0])))
             )
 
+        # If a camera exists, create the camera and dont add the node to the graph
+        # TODO only process the first camera, ignore the rest
+        # TODO assumes the camera node is child of the world frame
+        # TODO will only read perspective camera
+        if "camera" in child and camera is None:
+            cam_idx = child["camera"]
+            try:
+                camera = _cam_from_gltf(header["cameras"][cam_idx])
+            except KeyError:
+                log.debug("GLTF camera is not fully-defined")
+            if camera:
+                camera_transform = kwargs["matrix"]
+            continue
+
         # treat node metadata similarly to mesh metadata
         if isinstance(child.get("extras"), dict):
             kwargs["metadata"] = child["extras"]
@@ -1780,6 +1810,8 @@ def _read_buffers(
         "geometry": meshes,
         "graph": graph,
         "base_frame": base_frame,
+        "camera": camera,
+        "camera_transform": camera_transform,
     }
     try:
         # load any scene extras into scene.metadata
@@ -1799,6 +1831,38 @@ def _read_buffers(
     return result
 
 
+def _cam_from_gltf(cam):
+    """
+    Convert a gltf perspective camera to trimesh.
+
+    The retrieved camera will have default resolution, since the gltf specification
+    does not contain it.
+
+    If the camera is not perspective will return None.
+    If the camera is perspective but is missing fields, will raise `KeyError`
+
+    Parameters
+    ------------
+    cam : dict
+      Camera represented as a dictionary according to glTF
+
+    Returns
+    -------------
+    camera : trimesh.scene.cameras.Camera
+      Trimesh camera object
+    """
+    if "perspective" not in cam:
+        return
+    name = cam.get("name")
+    znear = cam["perspective"]["znear"]
+    aspect_ratio = cam["perspective"]["aspectRatio"]
+    yfov = np.degrees(cam["perspective"]["yfov"])
+
+    fov = (aspect_ratio * yfov, yfov)
+
+    return Camera(name=name, fov=fov, z_near=znear)
+
+
 def _convert_camera(camera):
     """
     Convert a trimesh camera to a GLTF camera.
@@ -2049,7 +2113,6 @@ def get_schema():
     """
     # replace references
     # get zip resolver to access referenced assets
-    from ..resolvers import ZipResolver
     from ..schemas import resolve
 
     # get a blob of a zip file including the GLTF 2.0 schema
diff --git a/trimesh/exchange/load.py b/trimesh/exchange/load.py
index 2781485ac..d2b2bdce3 100644
--- a/trimesh/exchange/load.py
+++ b/trimesh/exchange/load.py
@@ -413,6 +413,12 @@ def handle_scene():
         else:
             scene = Scene(geometry)
 
+        # camera, if it exists
+        camera = kwargs.get("camera")
+        if camera:
+            scene.camera = camera
+            scene.camera_transform = kwargs.get("camera_transform")
+
         if "base_frame" in kwargs:
             scene.graph.base_frame = kwargs["base_frame"]
         metadata = kwargs.get("metadata")
diff --git a/trimesh/graph.py b/trimesh/graph.py
index f3f670df2..9072ab306 100644
--- a/trimesh/graph.py
+++ b/trimesh/graph.py
@@ -281,16 +281,18 @@ def shared_edges(faces_a, faces_b):
     return shared
 
 
-def facets(mesh, engine=None):
+def facets(mesh, engine=None, facet_threshold: Optional[Number] = None):
     """
     Find the list of parallel adjacent faces.
 
     Parameters
     -----------
-    mesh :  trimesh.Trimesh
+    mesh : trimesh.Trimesh
     engine : str
-       Which graph engine to use:
-       ('scipy', 'networkx')
+      Which graph engine to use:
+      ('scipy', 'networkx')
+    facet_threshold : float
+      Threshold for two facets to be considered coplanar
 
     Returns
     ---------
@@ -298,6 +300,8 @@ def facets(mesh, engine=None):
         Groups of face indexes of
         parallel adjacent faces.
     """
+    if facet_threshold is None:
+        facet_threshold = tol.facet_threshold
     # what is the radius of a circle that passes through the perpendicular
     # projection of the vector between the two non- shared vertices
     # onto the shared edge, with the face normal from the two adjacent faces
@@ -314,7 +318,7 @@ def facets(mesh, engine=None):
     # if span is zero we know faces are small/parallel
     nonzero = np.abs(span) > tol.zero
     # faces with a radii/span ratio larger than a threshold pass
-    parallel[nonzero] = (radii[nonzero] / span[nonzero]) ** 2 > tol.facet_threshold
+    parallel[nonzero] = (radii[nonzero] / span[nonzero]) ** 2 > facet_threshold
 
     # run connected components on the parallel faces to group them
     components = connected_components(
@@ -525,7 +529,7 @@ def split_traversal(traversal, edges, edges_hash=None):
     # hash each edge so we can compare to edge set
     trav_hash = grouping.hashable_rows(np.sort(trav_edge, axis=1))
     # check if each edge is contained in edge set
-    contained = np.in1d(trav_hash, edges_hash)
+    contained = np.isin(trav_hash, edges_hash)
 
     # exit early if every edge of traversal exists
     if contained.all():
@@ -547,7 +551,7 @@ def split_traversal(traversal, edges, edges_hash=None):
             continue
         # make sure it's not already closed
         edge = np.sort([t[0], t[-1]])
-        if edge.ptp() == 0:
+        if np.ptp(edge) == 0:
             continue
         close = grouping.hashable_rows(edge.reshape((1, 2)))[0]
         # if we need the edge add it
diff --git a/trimesh/grouping.py b/trimesh/grouping.py
index 84c8934be..24d9bc081 100644
--- a/trimesh/grouping.py
+++ b/trimesh/grouping.py
@@ -204,7 +204,7 @@ def hashable_rows(data: ArrayLike, digits=None) -> NDArray:
             hashable = np.zeros(len(as_int), dtype=np.uint64)
             # offset to the middle of the unsigned integer range
             # this array should contain only positive values
-            bitbang = (as_int + threshold).astype(np.uint64).T
+            bitbang = as_int.astype(np.uint64).T + threshold
             # loop through each column and bitwise xor to combine
             # make sure as_int is int64 otherwise bit offset won't work
             for offset, column in enumerate(bitbang):
diff --git a/trimesh/interval.py b/trimesh/interval.py
index 1dbc98362..c8dcfdb9a 100644
--- a/trimesh/interval.py
+++ b/trimesh/interval.py
@@ -27,7 +27,7 @@ def intersection(a: ArrayLike, b: NDArray[float64]) -> NDArray[float64]:
     --------------
     inter : (2, ) or (2, 2) float
       The unioned range from the two inputs,
-      if not `inter.ptp(axis=1)` will be zero.
+      if not np.ptp(`inter, axis=1)` will be zero.
     """
     a = np.array(a, dtype=np.float64)
     b = np.array(b, dtype=np.float64)
diff --git a/trimesh/nsphere.py b/trimesh/nsphere.py
index a23de86cc..a2371b7a6 100644
--- a/trimesh/nsphere.py
+++ b/trimesh/nsphere.py
@@ -67,7 +67,7 @@ def minimum_nsphere(obj):
     # this used to pass qhull_options 'QbB' to Voronoi however this had a bug somewhere
     # to avoid this we scale to a unit cube ourselves inside this function
     points_origin = points.min(axis=0)
-    points_scale = points.ptp(axis=0).min()
+    points_scale = np.ptp(points, axis=0).min()
     points = (points - points_origin) / points_scale
 
     # if all of the points are on an n-sphere already the voronoi
@@ -169,7 +169,7 @@ def residuals(center):
 
     radii = util.row_norm(points - center_result)
     radius = radii.mean()
-    error = radii.ptp()
+    error = np.ptp(radii)
     return center_result, radius, error
 
 
diff --git a/trimesh/path/entities.py b/trimesh/path/entities.py
index c9ad6f8cf..b09b25062 100644
--- a/trimesh/path/entities.py
+++ b/trimesh/path/entities.py
@@ -379,7 +379,7 @@ def origin(self):
     @origin.setter
     def origin(self, value):
         value = int(value)
-        if not hasattr(self, "points") or self.points.ptp() == 0:
+        if not hasattr(self, "points") or np.ptp(self.points) == 0:
             self.points = np.ones(3, dtype=np.int64) * value
         else:
             self.points[0] = value
diff --git a/trimesh/path/exchange/misc.py b/trimesh/path/exchange/misc.py
index e99d56429..2ae3423ed 100644
--- a/trimesh/path/exchange/misc.py
+++ b/trimesh/path/exchange/misc.py
@@ -1,6 +1,7 @@
 import numpy as np
 
 from ... import graph, grouping, util
+from ...constants import tol_path
 from ..entities import Arc, Line
 
 
@@ -62,7 +63,7 @@ def lines_to_path(lines):
         # convert lines to even number of (n, dimension) points
         lines = lines.reshape((-1, dimension))
         # merge duplicate vertices
-        unique, inverse = grouping.unique_rows(lines)
+        unique, inverse = grouping.unique_rows(lines, digits=tol_path.merge_digits)
         # use scipy edges_to_path to skip creating
         # a bajillion individual line entities which
         # will be super slow vs. fewer polyline entities
diff --git a/trimesh/path/exchange/svg_io.py b/trimesh/path/exchange/svg_io.py
index 0e207a8ab..b3d189581 100644
--- a/trimesh/path/exchange/svg_io.py
+++ b/trimesh/path/exchange/svg_io.py
@@ -341,7 +341,7 @@ def __init__(self, lines):
                 )
                 # all arcs share the same center radius and rotation
                 closed = False
-                if verts[:, 4:].ptp(axis=0).mean() < 1e-3:
+                if np.ptp(verts[:, 4:], axis=0).mean() < 1e-3:
                     start, end = verts[:, :2], verts[:, 2:4]
                     # if every end point matches the start point of a new
                     # arc that means this is really a closed circle made
diff --git a/trimesh/path/packing.py b/trimesh/path/packing.py
index 716a3a43a..8af416387 100644
--- a/trimesh/path/packing.py
+++ b/trimesh/path/packing.py
@@ -210,7 +210,7 @@ def rectangles_single(extents, size=None, shuffle=False, rotate=True, random=Non
         # if no bounds are passed start it with the size of a large
         # rectangle exactly which will require re-rooting for
         # subsequent insertions
-        root_bounds = [[0.0] * dimension, extents[extents.ptp(axis=1).argmax()]]
+        root_bounds = [[0.0] * dimension, extents[np.ptp(extents, axis=1).argmax()]]
     else:
         # restrict the bounds to passed size and disallow re-rooting
         root_bounds = [[0.0] * dimension, size]
@@ -230,7 +230,7 @@ def rectangles_single(extents, size=None, shuffle=False, rotate=True, random=Non
             # get the size of the current root node
             bounds = root.bounds
             # current extents
-            current = bounds.ptp(axis=0)
+            current = np.ptp(bounds, axis=0)
 
             # pick the direction which has the least hyper-volume.
             best = np.inf
@@ -280,7 +280,7 @@ def rectangles_single(extents, size=None, shuffle=False, rotate=True, random=Non
             new_root.child = [RectangleBin(bounds_ori), RectangleBin(bounds_ins)]
 
             # insert the original sheet into the new tree
-            root_offset = new_root.child[0].insert(bounds.ptp(axis=0), rotate=rotate)
+            root_offset = new_root.child[0].insert(np.ptp(bounds, axis=0), rotate=rotate)
             # we sized the cells so original tree would fit
             assert root_offset is not None
 
@@ -477,7 +477,7 @@ def rectangles(
         )
 
         count = insert.sum()
-        extents_all = bounds.reshape((-1, dim)).ptp(axis=0)
+        extents_all = np.ptp(bounds.reshape((-1, dim)), axis=0)
 
         if quanta is not None:
             # compute the density using an upsized quanta
@@ -560,7 +560,7 @@ def images(
         assert insert.all()
         # re-index bounds back to original indexes
         bounds = bounds[inverse]
-        assert np.allclose(bounds.ptp(axis=1), [i.size for i in images])
+        assert np.allclose(np.ptp(bounds, axis=1), [i.size for i in images])
     else:
         # use the number of pixels as the rectangle size
         bounds, insert = rectangles(
@@ -580,7 +580,7 @@ def images(
 
     # offsets should be integer multiple of pizels
     offset = bounds[:, 0].round().astype(int)
-    extents = bounds.reshape((-1, 2)).ptp(axis=0) + (spacing * 2)
+    extents = np.ptp(bounds.reshape((-1, 2)), axis=0) + (spacing * 2)
     size = extents.round().astype(int)
     if power_resize:
         # round up all dimensions to powers of 2
@@ -719,7 +719,7 @@ def roll_transform(bounds, extents):
         return []
 
     # find the size of the AABB of the passed bounds
-    passed = bounds.ptp(axis=1)
+    passed = np.ptp(bounds, axis=1)
     # zeroth index is 2D, `1` is 3D
     dimension = passed.shape[1]
 
@@ -759,7 +759,7 @@ def roll_transform(bounds, extents):
         rolled = np.roll(extents, roll, axis=1)
         # check to see if the rolled original extents
         # match the requested bounding box
-        ok = (passed - rolled).ptp(axis=1) < _TOL_ZERO
+        ok = np.ptp((passed - rolled), axis=1) < _TOL_ZERO
         if not ok.any():
             continue
 
diff --git a/trimesh/path/path.py b/trimesh/path/path.py
index 50ce9702f..8d5cc8b4d 100644
--- a/trimesh/path/path.py
+++ b/trimesh/path/path.py
@@ -328,7 +328,7 @@ def extents(self):
         extents : (dimension,) float
           Edge length of AABB
         """
-        return self.bounds.ptp(axis=0)
+        return np.ptp(self.bounds, axis=0)
 
     def convert_units(self, desired: str, guess: bool = False):
         """
@@ -833,7 +833,7 @@ def to_planar(self, to_2D=None, normal=None, check=True):
         # Z values of vertices which are referenced
         heights = flat[referenced][:, 2]
         # points are not on a plane because Z varies
-        if heights.ptp() > tol.planar:
+        if np.ptp(heights) > tol.planar:
             # since Z is inconsistent set height to zero
             height = 0.0
             if check:
diff --git a/trimesh/path/polygons.py b/trimesh/path/polygons.py
index 3edf453fb..887908a3b 100644
--- a/trimesh/path/polygons.py
+++ b/trimesh/path/polygons.py
@@ -394,7 +394,7 @@ def medial_axis(polygon: Polygon, resolution: Optional[Number] = None, clip=None
     # a circle will have a single point medial axis
     if len(polygon.interiors) == 0:
         # what is the approximate scale of the polygon
-        scale = np.reshape(polygon.bounds, (2, 2)).ptp(axis=0).max()
+        scale = np.ptp(np.reshape(polygon.bounds, (2, 2)), axis=0).max()
         # a (center, radius, error) tuple
         fit = fit_circle_check(polygon.exterior.coords, scale=scale)
         # is this polygon in fact a circle
@@ -413,7 +413,7 @@ def medial_axis(polygon: Polygon, resolution: Optional[Number] = None, clip=None
     from shapely import vectorized
 
     if resolution is None:
-        resolution = np.reshape(polygon.bounds, (2, 2)).ptp(axis=0).max() / 100
+        resolution = np.ptp(np.reshape(polygon.bounds, (2, 2)), axis=0).max() / 100
 
     # get evenly spaced points on the polygons boundaries
     samples = resample_boundaries(polygon=polygon, resolution=resolution, clip=clip)
@@ -442,7 +442,7 @@ def medial_axis(polygon: Polygon, resolution: Optional[Number] = None, clip=None
 
     if tol.strict:
         # make sure we didn't screw up indexes
-        assert (vertices[edges_final] - voronoi.vertices[edges]).ptp() < 1e-5
+        assert np.ptp(vertices[edges_final] - voronoi.vertices[edges]) < 1e-5
 
     return edges_final, vertices
 
@@ -519,7 +519,7 @@ def polygon_scale(polygon):
     scale : float
       Length of AABB diagonal
     """
-    extents = np.reshape(polygon.bounds, (2, 2)).ptp(axis=0)
+    extents = np.ptp(np.reshape(polygon.bounds, (2, 2)), axis=0)
     scale = (extents**2).sum() ** 0.5
 
     return scale
@@ -589,7 +589,7 @@ def sample(polygon, count, factor=1.5, max_iter=10):
 
     # get size of bounding box
     bounds = np.reshape(polygon.bounds, (2, 2))
-    extents = bounds.ptp(axis=0)
+    extents = np.ptp(bounds, axis=0)
 
     # how many points to check per loop iteration
     per_loop = int(count * factor)
@@ -663,7 +663,7 @@ def repair_invalid(polygon, scale=None, rtol=0.5):
         return basic
 
     if scale is None:
-        distance = 0.002 * np.reshape(polygon.bounds, (2, 2)).ptp(axis=0).mean()
+        distance = 0.002 * np.ptp(np.reshape(polygon.bounds, (2, 2)), axis=0).mean()
     else:
         distance = 0.002 * scale
 
@@ -834,7 +834,7 @@ def projected(
         padding += float(apad)
     if rpad is not None:
         # get the 2D scale as the longest side of the AABB
-        scale = vertices_2D.ptp(axis=0).max()
+        scale = np.ptp(vertices_2D, axis=0).max()
         # apply the scale-relative padding
         padding += float(rpad) * scale
 
diff --git a/trimesh/path/raster.py b/trimesh/path/raster.py
index 1fc386f07..bc6a67fef 100644
--- a/trimesh/path/raster.py
+++ b/trimesh/path/raster.py
@@ -62,7 +62,7 @@ def rasterize(path, pitch=None, origin=None, resolution=None, fill=True, width=N
 
     # if resolution is None make it larger than path
     if resolution is None:
-        span = np.vstack((path.bounds, origin)).ptp(axis=0)
+        span = np.ptp(np.vstack((path.bounds, origin)), axis=0)
         resolution = np.ceil(span / pitch) + 2
     # get resolution as a (2,) int tuple
     resolution = np.asanyarray(resolution, dtype=np.int64)
diff --git a/trimesh/path/simplify.py b/trimesh/path/simplify.py
index 04c5b6091..df5859ddb 100644
--- a/trimesh/path/simplify.py
+++ b/trimesh/path/simplify.py
@@ -130,7 +130,7 @@ def is_circle(points, scale, verbose=False):
     if np.linalg.norm(points[0] - points[-1]) > tol.merge:
         return None
 
-    box = points.ptp(axis=0)
+    box = np.ptp(points, axis=0)
     # the bounding box size of the points
     # check aspect ratio as an early exit if the path is not a circle
     aspect = np.divide(*box)
diff --git a/trimesh/points.py b/trimesh/points.py
index 7ff60ec25..d409e3a3a 100644
--- a/trimesh/points.py
+++ b/trimesh/points.py
@@ -558,7 +558,7 @@ def extents(self):
         extents : (3,) float
           Edge length of axis aligned bounding box
         """
-        return self.bounds.ptp(axis=0)
+        return np.ptp(self.bounds, axis=0)
 
     @property
     def centroid(self):
diff --git a/trimesh/primitives.py b/trimesh/primitives.py
index 7718d9c7f..7fcbe8875 100644
--- a/trimesh/primitives.py
+++ b/trimesh/primitives.py
@@ -724,7 +724,7 @@ def __init__(self, extents=None, transform=None, bounds=None, mutable=True):
             if bounds.shape != (2, 3):
                 raise ValueError("`bounds` must be (2, 3) float")
             # create extents from AABB
-            extents = bounds.ptp(axis=0)
+            extents = np.ptp(bounds, axis=0)
             # translate to the center of the box
             transform = np.eye(4)
             transform[:3, 3] = bounds[0] + extents / 2.0
diff --git a/trimesh/proximity.py b/trimesh/proximity.py
index e7a423162..3909bdf87 100644
--- a/trimesh/proximity.py
+++ b/trimesh/proximity.py
@@ -184,7 +184,7 @@ def closest_point(mesh, points):
 
     # however: same closest point on two different faces
     # find the best one and correct triangle ids if necessary
-    check_distance = two_dists.ptp(axis=1) < tol.merge
+    check_distance = np.ptp(two_dists, axis=1) < tol.merge
     check_magnitude = np.all(np.abs(two_dists) > tol.merge, axis=1)
 
     # mask results where corrections may be apply
diff --git a/trimesh/scene/cameras.py b/trimesh/scene/cameras.py
index 283d63712..2b83efd36 100644
--- a/trimesh/scene/cameras.py
+++ b/trimesh/scene/cameras.py
@@ -318,7 +318,7 @@ def look_at(points, fov, rotation=None, distance=None, center=None, pad=None):
 
     if center is None:
         # Find the center of the points' AABB in camera frame
-        center_c = points_c.min(axis=0) + 0.5 * points_c.ptp(axis=0)
+        center_c = points_c.min(axis=0) + 0.5 * np.ptp(points_c, axis=0)
     else:
         # Transform center to camera frame
         center_c = rinv.dot(center)
diff --git a/trimesh/transformations.py b/trimesh/transformations.py
index 2a5708fad..ff179a336 100644
--- a/trimesh/transformations.py
+++ b/trimesh/transformations.py
@@ -209,7 +209,7 @@ def identity_matrix():
     >>> I = identity_matrix()
     >>> np.allclose(I, np.dot(I, I))
     True
-    >>> np.sum(I), np.trace(I)
+    >>> float(np.sum(I)), float(np.trace(I))
     (4.0, 4.0)
     >>> np.allclose(I, np.identity(4))
     True
@@ -255,7 +255,7 @@ def translation_from_matrix(matrix):
     True
 
     """
-    return np.array(matrix, copy=False)[:3, 3].copy()
+    return np.asarray(matrix)[:3, 3].copy()
 
 
 def reflection_matrix(point, normal):
@@ -296,7 +296,7 @@ def reflection_from_matrix(matrix):
     True
 
     """
-    M = np.array(matrix, dtype=np.float64, copy=False)
+    M = np.asarray(matrix, dtype=np.float64)
     # normal: unit eigenvector corresponding to eigenvalue -1
     w, V = np.linalg.eig(M[:3, :3])
     i = np.where(abs(np.real(w) + 1.0) < 1e-8)[0]
@@ -384,7 +384,7 @@ def rotation_matrix(angle, direction, point=None):
 
     # if point is specified, rotation is not around origin
     if point is not None:
-        point = np.array(point[:3], dtype=np.float64, copy=False)
+        point = np.asarray(point[:3], dtype=np.float64)
         M[:3, 3] = point - np.dot(M[:3, :3], point)
 
     # return symbolic angles as sympy Matrix objects
@@ -407,7 +407,7 @@ def rotation_from_matrix(matrix):
     True
 
     """
-    R = np.array(matrix, dtype=np.float64, copy=False)
+    R = np.asarray(matrix, dtype=np.float64)
     R33 = R[:3, :3]
     # direction: unit eigenvector of R33 corresponding to eigenvalue of 1
     w, W = np.linalg.eig(R33.T)
@@ -486,7 +486,7 @@ def scale_from_matrix(matrix):
     True
 
     """
-    M = np.array(matrix, dtype=np.float64, copy=False)
+    M = np.asarray(matrix, dtype=np.float64)
     M33 = M[:3, :3]
     factor = np.trace(M33) - 2.0
     try:
@@ -541,11 +541,11 @@ def projection_matrix(point, normal, direction=None, perspective=None, pseudo=Fa
 
     """
     M = np.identity(4)
-    point = np.array(point[:3], dtype=np.float64, copy=False)
+    point = np.asarray(point[:3], dtype=np.float64)
     normal = unit_vector(normal[:3])
     if perspective is not None:
         # perspective projection
-        perspective = np.array(perspective[:3], dtype=np.float64, copy=False)
+        perspective = np.asarray(perspective[:3], dtype=np.float64)
         M[0, 0] = M[1, 1] = M[2, 2] = np.dot(perspective - point, normal)
         M[:3, :3] -= np.outer(perspective, normal)
         if pseudo:
@@ -558,7 +558,7 @@ def projection_matrix(point, normal, direction=None, perspective=None, pseudo=Fa
         M[3, 3] = np.dot(perspective, normal)
     elif direction is not None:
         # parallel projection
-        direction = np.array(direction[:3], dtype=np.float64, copy=False)
+        direction = np.asarray(direction[:3], dtype=np.float64)
         scale = np.dot(direction, normal)
         M[:3, :3] -= np.outer(direction, normal) / scale
         M[:3, 3] = direction * (np.dot(point, normal) / scale)
@@ -601,7 +601,7 @@ def projection_from_matrix(matrix, pseudo=False):
     True
 
     """
-    M = np.array(matrix, dtype=np.float64, copy=False)
+    M = np.asarray(matrix, dtype=np.float64)
     M33 = M[:3, :3]
     w, V = np.linalg.eig(M)
     i = np.where(abs(np.real(w) - 1.0) < 1e-8)[0]
@@ -746,7 +746,7 @@ def shear_from_matrix(matrix):
     True
 
     """
-    M = np.array(matrix, dtype=np.float64, copy=False)
+    M = np.asarray(matrix, dtype=np.float64)
     M33 = M[:3, :3]
     # normal: cross independent eigenvectors corresponding to the eigenvalue 1
     w, V = np.linalg.eig(M33)
@@ -801,8 +801,8 @@ def decompose_matrix(matrix):
     True
     >>> S = scale_matrix(0.123)
     >>> scale, shear, angles, trans, persp = decompose_matrix(S)
-    >>> scale[0]
-    0.123
+    >>> bool(np.isclose(scale[0], 0.123))
+    True
     >>> R0 = euler_matrix(1, 2, 3)
     >>> scale, shear, angles, trans, persp = decompose_matrix(R0)
     >>> R1 = euler_matrix(*angles)
@@ -810,7 +810,7 @@ def decompose_matrix(matrix):
     True
 
     """
-    M = np.array(matrix, dtype=np.float64, copy=True).T
+    M = np.array(matrix, dtype=np.float64).T
     if abs(M[3, 3]) < _EPS:
         raise ValueError("M[3, 3] is zero")
     M /= M[3, 3]
@@ -982,8 +982,8 @@ def affine_matrix_from_points(v0, v1, shear=True, scale=True, usesvd=True):
     More examples in superimposition_matrix()
 
     """
-    v0 = np.array(v0, dtype=np.float64, copy=True)
-    v1 = np.array(v1, dtype=np.float64, copy=True)
+    v0 = np.array(v0, dtype=np.float64)
+    v1 = np.array(v1, dtype=np.float64)
 
     ndims = v0.shape[0]
     if ndims < 2 or v0.shape[1] < ndims or v0.shape != v1.shape:
@@ -1097,8 +1097,8 @@ def superimposition_matrix(v0, v1, scale=False, usesvd=True):
     True
 
     """
-    v0 = np.array(v0, dtype=np.float64, copy=False)[:3]
-    v1 = np.array(v1, dtype=np.float64, copy=False)[:3]
+    v0 = np.asarray(v0, dtype=np.float64)[:3]
+    v1 = np.asarray(v1, dtype=np.float64)[:3]
     return affine_matrix_from_points(v0, v1, shear=False, scale=scale, usesvd=usesvd)
 
 
@@ -1203,7 +1203,7 @@ def euler_from_matrix(matrix, axes="sxyz"):
     j = _NEXT_AXIS[i + parity]
     k = _NEXT_AXIS[i - parity + 1]
 
-    M = np.array(matrix, dtype=np.float64, copy=False)[:3, :3]
+    M = np.asarray(matrix, dtype=np.float64)[:3, :3]
     if repetition:
         sy = np.sqrt(M[i, j] * M[i, j] + M[i, k] * M[i, k])
         if sy > _EPS:
@@ -1335,7 +1335,7 @@ def quaternion_matrix(quaternion):
 
 
     """
-    q = np.array(quaternion, dtype=np.float64, copy=True).reshape((-1, 4))
+    q = np.array(quaternion, dtype=np.float64).reshape((-1, 4))
     n = np.einsum("ij,ij->i", q, q)
     # how many entries do we have
     num_qs = len(n)
@@ -1402,7 +1402,7 @@ def quaternion_from_matrix(matrix, isprecise=False):
     True
 
     """
-    M = np.array(matrix, dtype=np.float64, copy=False)[:4, :4]
+    M = np.asarray(matrix, dtype=np.float64)[:4, :4]
     if isprecise:
         q = np.empty((4,))
         t = np.trace(M)
@@ -1482,7 +1482,7 @@ def quaternion_conjugate(quaternion):
     True
 
     """
-    q = np.array(quaternion, dtype=np.float64, copy=True)
+    q = np.array(quaternion, dtype=np.float64)
     np.negative(q[1:], q[1:])
     return q
 
@@ -1496,7 +1496,7 @@ def quaternion_inverse(quaternion):
     True
 
     """
-    q = np.array(quaternion, dtype=np.float64, copy=True)
+    q = np.array(quaternion, dtype=np.float64)
     np.negative(q[1:], q[1:])
     return q / np.dot(q, q)
 
@@ -1518,7 +1518,7 @@ def quaternion_imag(quaternion):
     array([0., 1., 2.])
 
     """
-    return np.array(quaternion[1:4], dtype=np.float64, copy=True)
+    return np.array(quaternion[1:4], dtype=np.float64)
 
 
 def quaternion_slerp(quat0, quat1, fraction, spin=0, shortestpath=True):
@@ -1743,8 +1743,8 @@ def arcball_map_to_sphere(point, center, radius):
 
 def arcball_constrain_to_axis(point, axis):
     """Return sphere point perpendicular to axis."""
-    v = np.array(point, dtype=np.float64, copy=True)
-    a = np.array(axis, dtype=np.float64, copy=True)
+    v = np.array(point, dtype=np.float64)
+    a = np.array(axis, dtype=np.float64)
     v -= a * np.dot(a, v)  # on plane
     n = vector_norm(v)
     if n > _EPS:
@@ -1759,7 +1759,7 @@ def arcball_constrain_to_axis(point, axis):
 
 def arcball_nearest_axis(point, axes):
     """Return axis, which arc is nearest to point."""
-    point = np.array(point, dtype=np.float64, copy=False)
+    point = np.asarray(point, dtype=np.float64)
     nearest = None
     mx = -1.0
     for axis in axes:
@@ -1826,13 +1826,13 @@ def vector_norm(data, axis=None, out=None):
     >>> vector_norm(v, axis=1, out=n)
     >>> np.allclose(n, np.sqrt(np.sum(v*v, axis=1)))
     True
-    >>> vector_norm([])
+    >>> float(vector_norm([]))
     0.0
-    >>> vector_norm([1])
+    >>> float(vector_norm([1]))
     1.0
 
     """
-    data = np.array(data, dtype=np.float64, copy=True)
+    data = np.array(data, dtype=np.float64)
     if out is None:
         if data.ndim == 1:
             return np.sqrt(np.dot(data, data))
@@ -1868,18 +1868,18 @@ def unit_vector(data, axis=None, out=None):
     True
     >>> list(unit_vector([]))
     []
-    >>> list(unit_vector([1]))
+    >>> [float(i) for i in unit_vector([1])]
     [1.0]
 
     """
     if out is None:
-        data = np.array(data, dtype=np.float64, copy=True)
+        data = np.array(data, dtype=np.float64)
         if data.ndim == 1:
             data /= np.sqrt(np.dot(data, data))
             return data
     else:
         if out is not data:
-            out[:] = np.array(data, copy=False)
+            out[:] = np.asarray(data)
         data = out
     length = np.atleast_1d(np.sum(data * data, axis))
     np.sqrt(length, length)
@@ -1894,11 +1894,11 @@ def random_vector(size):
     """Return array of random doubles in the half-open interval [0.0, 1.0).
 
     >>> v = random_vector(10000)
-    >>> np.all(v >= 0) and np.all(v < 1)
+    >>> bool(np.all(v >= 0) and np.all(v < 1))
     True
     >>> v0 = random_vector(10)
     >>> v1 = random_vector(10)
-    >>> np.any(v0 == v1)
+    >>> bool(np.any(v0 == v1))
     False
 
     """
@@ -1950,8 +1950,8 @@ def angle_between_vectors(v0, v1, directed=True, axis=0):
     True
 
     """
-    v0 = np.array(v0, dtype=np.float64, copy=False)
-    v1 = np.array(v1, dtype=np.float64, copy=False)
+    v0 = np.asarray(v0, dtype=np.float64)
+    v1 = np.asarray(v1, dtype=np.float64)
     dot = np.sum(v0 * v1, axis=axis)
     dot /= vector_norm(v0, axis=axis) * vector_norm(v1, axis=axis)
 
@@ -2001,9 +2001,9 @@ def is_same_transform(matrix0, matrix1):
     False
 
     """
-    matrix0 = np.array(matrix0, dtype=np.float64, copy=True)
+    matrix0 = np.array(matrix0, dtype=np.float64)
     matrix0 /= matrix0[3, 3]
-    matrix1 = np.array(matrix1, dtype=np.float64, copy=True)
+    matrix1 = np.array(matrix1, dtype=np.float64)
     matrix1 /= matrix1[3, 3]
     return np.allclose(matrix0, matrix1)
 
@@ -2251,13 +2251,13 @@ def is_rigid(matrix, epsilon=1e-8):
         return False
 
     # make sure last row has no scaling
-    if (matrix[-1] - [0, 0, 0, 1]).ptp() > epsilon:
+    if np.ptp(matrix[-1] - [0, 0, 0, 1]) > epsilon:
         return False
 
     # check dot product of rotation against transpose
     check = np.dot(matrix[:3, :3], matrix[:3, :3].T) - _IDENTITY[:3, :3]
 
-    return check.ptp() < epsilon
+    return np.ptp(check) < epsilon
 
 
 def scale_and_translate(scale=None, translate=None):
diff --git a/trimesh/triangles.py b/trimesh/triangles.py
index a65d8e32d..55e339635 100644
--- a/trimesh/triangles.py
+++ b/trimesh/triangles.py
@@ -335,9 +335,7 @@ def windings_aligned(triangles, normals_compare):
     """
     triangles = np.asanyarray(triangles, dtype=np.float64)
     if not util.is_shape(triangles, (-1, 3, 3), allow_zeros=True):
-        raise ValueError(
-            f"triangles must have shape (n, 3, 3), got {triangles.shape!s}"
-        )
+        raise ValueError(f"triangles must have shape (n, 3, 3), got {triangles.shape!s}")
     normals_compare = np.asanyarray(normals_compare, dtype=np.float64)
 
     calculated, valid = normals(triangles)
diff --git a/trimesh/typed.py b/trimesh/typed.py
index a95611934..cc9990bfb 100644
--- a/trimesh/typed.py
+++ b/trimesh/typed.py
@@ -5,6 +5,7 @@
     IO,
     Any,
     BinaryIO,
+    Mapping,
     Optional,
     TextIO,
     Union,
@@ -58,4 +59,5 @@
     "Tuple",
     "float64",
     "int64",
+    "Mapping",
 ]
diff --git a/trimesh/util.py b/trimesh/util.py
index e53821b1a..309a77b71 100644
--- a/trimesh/util.py
+++ b/trimesh/util.py
@@ -2234,7 +2234,7 @@ def allclose(a, b, atol: float = 1e-8):
     bool indicating if all elements are within `atol`.
     """
     #
-    return float((a - b).ptp()) < atol
+    return float(np.ptp(a - b)) < atol
 
 
 class FunctionRegistry(Mapping):
diff --git a/trimesh/viewer/windowed.py b/trimesh/viewer/windowed.py
index 32ca31230..085a442b3 100644
--- a/trimesh/viewer/windowed.py
+++ b/trimesh/viewer/windowed.py
@@ -562,9 +562,9 @@ def update_flags(self):
                 center = bounds.mean(axis=0)
                 # set the grid to the lowest Z position
                 # also offset by the scale to avoid interference
-                center[2] = bounds[0][2] - (bounds[:, 2].ptp() / 100)
+                center[2] = bounds[0][2] - (np.ptp(bounds[:, 2]) / 100)
                 # choose the side length by maximum XY length
-                side = bounds.ptp(axis=0)[:2].max()
+                side = np.ptp(bounds, axis=0)[:2].max()
                 # create an axis marker sized relative to the scene
                 grid_mesh = grid(side=side, count=4, transform=translation_matrix(center))
                 # convert the path to vertexlist args
diff --git a/trimesh/visual/color.py b/trimesh/visual/color.py
index 6b5ccae35..f78077f43 100644
--- a/trimesh/visual/color.py
+++ b/trimesh/visual/color.py
@@ -833,7 +833,7 @@ def interpolate(values, color_map=None, dtype=np.uint8):
     # make input always float
     values = np.asanyarray(values, dtype=np.float64).ravel()
     # scale values to 0.0 - 1.0 and get colors
-    colors = cmap((values - values.min()) / values.ptp())
+    colors = cmap((values - values.min()) / np.ptp(values))
     # convert to 0-255 RGBA
     rgba = to_rgba(colors, dtype=dtype)
 
diff --git a/trimesh/voxel/base.py b/trimesh/voxel/base.py
index 964d50166..6dc203f4d 100644
--- a/trimesh/voxel/base.py
+++ b/trimesh/voxel/base.py
@@ -68,9 +68,7 @@ def encoding(self, encoding):
         elif not isinstance(encoding, Encoding):
             raise ValueError(f"encoding must be an Encoding, got {encoding!s}")
         if len(encoding.shape) != 3:
-            raise ValueError(
-                f"encoding must be rank 3, got shape {encoding.shape!s}"
-            )
+            raise ValueError(f"encoding must be rank 3, got shape {encoding.shape!s}")
         if encoding.dtype != bool:
             raise ValueError(f"encoding must be binary, got {encoding.dtype}")
         self._data["encoding"] = encoding
diff --git a/trimesh/voxel/creation.py b/trimesh/voxel/creation.py
index 7859a3de6..17f1af0c4 100644
--- a/trimesh/voxel/creation.py
+++ b/trimesh/voxel/creation.py
@@ -144,11 +144,8 @@ def local_voxelize(mesh, point, pitch, radius, fill=True, **kwargs):
             for i in range(1, n + 1)
         ]
         contains = mesh.contains(np.asarray(representatives) * pitch + local_origin)
-
         where = np.where(contains)[0] + 1
-        # use in1d vs isin for older numpy versions
-        internal = np.in1d(regions.flatten(), where).reshape(regions.shape)
-
+        internal = np.isin(regions.flatten(), where).reshape(regions.shape)
         voxels = np.logical_or(voxels, internal)
 
     return base.VoxelGrid(voxels, tr.translation_matrix(local_origin))
diff --git a/trimesh/voxel/encoding.py b/trimesh/voxel/encoding.py
index 544eb3ae1..cd02c28d1 100644
--- a/trimesh/voxel/encoding.py
+++ b/trimesh/voxel/encoding.py
@@ -815,9 +815,7 @@ class TransposedEncoding(LazyIndexMap):
 
     def __init__(self, base_encoding, perm):
         if not isinstance(base_encoding, Encoding):
-            raise ValueError(
-                f"base_encoding must be an Encoding, got {base_encoding!s}"
-            )
+            raise ValueError(f"base_encoding must be an Encoding, got {base_encoding!s}")
         if len(base_encoding.shape) != len(perm):
             raise ValueError(
                 "base_encoding has %d ndims - cannot transpose with perm %s"
diff --git a/trimesh/voxel/morphology.py b/trimesh/voxel/morphology.py
index 0f60ae279..dacdf1359 100644
--- a/trimesh/voxel/morphology.py
+++ b/trimesh/voxel/morphology.py
@@ -22,9 +22,7 @@ def _dense(encoding, rank=None):
     elif isinstance(encoding, enc.Encoding):
         dense = encoding.dense
     else:
-        raise ValueError(
-            f"encoding must be np.ndarray or Encoding, got {encoding!s}"
-        )
+        raise ValueError(f"encoding must be np.ndarray or Encoding, got {encoding!s}")
     if rank:
         _assert_rank(dense, rank)
     return dense
@@ -36,9 +34,7 @@ def _sparse_indices(encoding, rank=None):
     elif isinstance(encoding, enc.Encoding):
         sparse_indices = encoding.sparse_indices
     else:
-        raise ValueError(
-            f"encoding must be np.ndarray or Encoding, got {encoding!s}"
-        )
+        raise ValueError(f"encoding must be np.ndarray or Encoding, got {encoding!s}")
 
     _assert_sparse_rank(sparse_indices, 3)
     return sparse_indices
diff --git a/trimesh/voxel/ops.py b/trimesh/voxel/ops.py
index f29b45ac0..8e1efed84 100644
--- a/trimesh/voxel/ops.py
+++ b/trimesh/voxel/ops.py
@@ -299,7 +299,7 @@ def boolean_sparse(a, b, operation=np.logical_and):
     # find the bounding box of both arrays
     extrema = np.array([a.min(axis=0), a.max(axis=0), b.min(axis=0), b.max(axis=0)])
     origin = extrema.min(axis=0) - 1
-    size = tuple(extrema.ptp(axis=0) + 2)
+    size = tuple(np.ptp(extrema, axis=0) + 2)
 
     # put nearby voxel arrays into same shape sparse array
     sp_a = sparse.COO((a - origin).T, data=np.ones(len(a), dtype=bool), shape=size)
diff --git a/trimesh/voxel/runlength.py b/trimesh/voxel/runlength.py
index 7b53d90b5..50d2c2f08 100644
--- a/trimesh/voxel/runlength.py
+++ b/trimesh/voxel/runlength.py
@@ -616,7 +616,7 @@ def rle_to_sparse(rle_data):
     try:
         while True:
             value = next(it)
-            counts = next(it)
+            counts = int(next(it))
             end = index + counts
             if value:
                 indices.append(np.arange(index, end, dtype=np.int64))
@@ -629,7 +629,7 @@ def rle_to_sparse(rle_data):
         return indices, values
 
     indices = np.concatenate(indices)
-    values = np.concatenate(values)
+    values = np.concatenate(values, dtype=rle_data.dtype)
     return indices, values
 
 


Test patch:
diff --git a/tests/generic.py b/tests/generic.py
index 12e74b454..6ebddd3cb 100644
--- a/tests/generic.py
+++ b/tests/generic.py
@@ -74,7 +74,7 @@ def output_text(*args, **kwargs):
 include_rendering = "INCLUDE_RENDERING" in argv
 
 if all_dependencies and not trimesh.ray.has_embree:
-    raise ValueError("missing embree!")
+    import embreex
 
 try:
     import sympy as sp
@@ -512,7 +512,7 @@ def check_fuze(fuze):
     # UV coordinates should be unmerged correctly
     assert len(fuze.visual.uv) == 664
     # UV coordinates shouldn't be all zero- ish
-    assert fuze.visual.uv[:, :2].ptp(axis=0).min() > 0.1
+    assert np.ptp(fuze.visual.uv[:, :2], axis=0).min() > 0.1
     # UV coordinates should be between zero and 1
     assert fuze.visual.uv.min() > -tol.merge
     assert fuze.visual.uv.max() < 1 + tol.merge
@@ -538,7 +538,7 @@ def check_fuze(fuze):
     viz = fuze.visual.to_color()
     assert viz.kind == "vertex"
     # should be actual colors defined
-    assert viz.vertex_colors.ptp(axis=0).ptp() != 0
+    assert np.ptp(np.ptp(viz.vertex_colors, axis=0)) != 0
     # shouldn't crash
     fuze.visual.copy()
     fuze.visual.material.copy()
diff --git a/tests/test_bounds.py b/tests/test_bounds.py
index b33d94b53..9b6d050a9 100644
--- a/tests/test_bounds.py
+++ b/tests/test_bounds.py
@@ -117,7 +117,7 @@ def test_2D(self):
             points = g.random((10, 2)) * [5, 1]
 
             # save the basic AABB of the points before rotation
-            rectangle_pre = points.ptp(axis=0)
+            rectangle_pre = g.np.ptp(points, axis=0)
 
             # rotate them by an increment
             TR = g.trimesh.transformations.planar_matrix(theta=theta)
@@ -129,10 +129,10 @@ def test_2D(self):
             # apply the calculated OBB
             oriented = g.trimesh.transform_points(points, T)
 
-            origin = oriented.min(axis=0) + oriented.ptp(axis=0) / 2.0
+            origin = oriented.min(axis=0) + g.np.ptp(oriented, axis=0) / 2.0
 
             # check to make sure the returned rectangle size is right
-            assert g.np.allclose(oriented.ptp(axis=0), rectangle)
+            assert g.np.allclose(g.np.ptp(oriented, axis=0), rectangle)
             # check to make sure the OBB consistently returns the
             # long axis in the same direction
             assert rectangle[0] > rectangle[1]
@@ -199,7 +199,7 @@ def test_bounding_egg(self):
         r = p.bounding_cylinder
 
         # transformed height should match source mesh
-        assert g.np.isclose(i.vertices[:, 2].ptp(), r.primitive.height, rtol=1e-6)
+        assert g.np.isclose(g.np.ptp(i.vertices[:, 2]), r.primitive.height, rtol=1e-6)
         # slightly inflated cylinder should contain all
         # vertices of the source mesh
         assert r.buffer(0.01).contains(p.vertices).all()
diff --git a/tests/test_camera.py b/tests/test_camera.py
index bafec8738..98ea3ecb6 100644
--- a/tests/test_camera.py
+++ b/tests/test_camera.py
@@ -93,7 +93,7 @@ def test_lookat(self):
             T = g.trimesh.scene.cameras.look_at(points + offset, fov)
 
             # check using trig
-            check = (points.ptp(axis=0)[:2] / 2.0) / g.np.tan(np.radians(fov / 2))
+            check = (np.ptp(points, axis=0)[:2] / 2.0) / g.np.tan(np.radians(fov / 2))
             check += points[:, 2].mean()
 
             # Z should be the same as maximum trig option
diff --git a/tests/test_color.py b/tests/test_color.py
index d19f26e7f..c05ffe008 100644
--- a/tests/test_color.py
+++ b/tests/test_color.py
@@ -26,7 +26,7 @@ def test_concatenate(self):
 
         a.visual.face_colors = [255, 0, 0]
         r = a + b
-        assert any(r.visual.face_colors.ptp(axis=0) > 1)
+        assert any(g.np.ptp(r.visual.face_colors, axis=0) > 1)
 
     def test_concatenate_empty_mesh(self):
         box = g.get_mesh("box.STL")
@@ -153,23 +153,23 @@ def test_smooth(self):
         # will put smoothed mesh into visuals cache
         s = m.smooth_shaded
         # every color should be default color
-        assert s.visual.face_colors.ptp(axis=0).max() == 0
+        assert g.np.ptp(s.visual.face_colors, axis=0).max() == 0
 
         # set one face to a different color
         m.visual.face_colors[0] = [255, 0, 0, 255]
 
         # cache should be dumped yo
         s1 = m.smooth_shaded
-        assert s1.visual.face_colors.ptp(axis=0).max() != 0
+        assert g.np.ptp(s1.visual.face_colors, axis=0).max() != 0
 
         # do the same check on vertex color
         m = g.get_mesh("featuretype.STL")
         s = m.smooth_shaded
         # every color should be default color
-        assert s.visual.vertex_colors.ptp(axis=0).max() == 0
+        assert g.np.ptp(s.visual.vertex_colors, axis=0).max() == 0
         m.visual.vertex_colors[g.np.arange(10)] = [255, 0, 0, 255]
         s1 = m.smooth_shaded
-        assert s1.visual.face_colors.ptp(axis=0).max() != 0
+        assert g.np.ptp(s1.visual.face_colors, axis=0).max() != 0
 
     def test_vertex(self):
         m = g.get_mesh("torus.STL")
diff --git a/tests/test_dae.py b/tests/test_dae.py
index 36b7fe110..7da5ec60d 100644
--- a/tests/test_dae.py
+++ b/tests/test_dae.py
@@ -63,7 +63,7 @@ def test_obj_roundtrip(self):
             s.export(path)
             # bring it back from outer space
             rec = g.trimesh.load(path, force="mesh")
-        assert rec.visual.uv.ptp(axis=0).ptp() > 1e-5
+        assert g.np.ptp(g.np.ptp(rec.visual.uv, axis=0)) > 1e-5
         assert s.visual.material.baseColorTexture.size == rec.visual.material.image.size
 
         conv = s.convert_units("inch")
diff --git a/tests/test_dxf.py b/tests/test_dxf.py
index a9866380d..33b5f4339 100644
--- a/tests/test_dxf.py
+++ b/tests/test_dxf.py
@@ -143,7 +143,7 @@ def test_versions(self):
             # count the number of entities in the path
             # this should be the same for every version
             E = g.np.array([len(paths[i].entities) for i in group], dtype=g.np.int64)
-            assert E.ptp() == 0
+            assert g.np.ptp(E) == 0
 
     def test_bulge(self):
         """
diff --git a/tests/test_gltf.py b/tests/test_gltf.py
index 95aec4634..91eb608e4 100644
--- a/tests/test_gltf.py
+++ b/tests/test_gltf.py
@@ -771,7 +771,7 @@ def test_vertex_colors(self):
         )
         # original mesh should have vertex colors
         assert m.visual.kind == "face"
-        assert m.visual.vertex_colors.ptp(axis=0).ptp() > 0
+        assert g.np.ptp(g.np.ptp(m.visual.vertex_colors, axis=0)) > 0
         # vertex colors should have survived import-export
         assert g.np.allclose(m.visual.vertex_colors, r.visual.vertex_colors)
 
@@ -1058,6 +1058,16 @@ def test_unitize_normals_null_values(self):
         # Check that the normals are still null
         assert g.np.allclose(reimported_mesh.vertex_normals[0], [0, 0, 0])
 
+    def test_no_indices(self):
+        # test mesh with no indices (faces should be generated correctly)
+        mesh = g.get_mesh("no_indices_3storybuilding.glb")
+        assert len(mesh.triangles) == 72
+
+        # the mesh is actually mode 5 with 4 vertices
+        # which as triangle strips would be 2 faces
+        mesh = g.get_mesh("Mesh_PrimitiveMode_04.gltf")
+        assert len(mesh.triangles) == 2
+
 
 if __name__ == "__main__":
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_grouping.py b/tests/test_grouping.py
index 52a75d160..515df67b7 100644
--- a/tests/test_grouping.py
+++ b/tests/test_grouping.py
@@ -215,11 +215,11 @@ def test_group_rows(self):
 
         gr = g.trimesh.grouping.group_rows(c)
         assert g.np.shape(gr) == (100, 2)
-        assert g.np.allclose(c[gr].ptp(axis=1), 0.0)
+        assert g.np.allclose(g.np.ptp(c[gr], axis=1), 0.0)
 
         gr = g.trimesh.grouping.group_rows(c, require_count=2)
         assert gr.shape == (100, 2)
-        assert g.np.allclose(c[gr].ptp(axis=1), 0.0)
+        assert g.np.allclose(g.np.ptp(c[gr], axis=1), 0.0)
 
         c = g.np.vstack((c, [1, 2, 3]))
         gr = g.trimesh.grouping.group_rows(c, require_count=2)
@@ -229,7 +229,7 @@ def test_group_rows(self):
         # should get the single element correctly
         assert len(grd) == 101
         assert sum(1 for i in grd if len(i) == 2) == 100
-        assert g.np.allclose(c[gr].ptp(axis=1), 0.0)
+        assert g.np.allclose(g.np.ptp(c[gr], axis=1), 0.0)
 
     def test_group_vector(self):
         x = g.np.linspace(-100, 100, 100)
diff --git a/tests/test_medial.py b/tests/test_medial.py
index 6aa957ff8..9621f67c1 100644
--- a/tests/test_medial.py
+++ b/tests/test_medial.py
@@ -27,7 +27,7 @@ def test_medial(self):
         # with midpoint at origin
         assert len(med.vertices) == 2
         assert len(med.entities) == 1
-        assert float(med.vertices.mean(axis=0).ptp()) < 1e-8
+        assert float(g.np.ptp(med.vertices.mean(axis=0))) < 1e-8
 
 
 if __name__ == "__main__":
diff --git a/tests/test_minimal.py b/tests/test_minimal.py
index 9075f31b6..9856dbd50 100644
--- a/tests/test_minimal.py
+++ b/tests/test_minimal.py
@@ -35,7 +35,7 @@ def test_path_exc(self):
         bounds, inserted = packing.rectangles_single([[1, 1], [2, 2]], size=[2, 4])
         assert inserted.all()
 
-        extents = bounds.reshape((-1, 2)).ptp(axis=0)
+        extents = np.ptp(bounds.reshape((-1, 2)), axis=0)
         assert np.allclose(extents, [2, 3])
         assert bounds.shape == (2, 2, 2)
         density = 5.0 / np.prod(extents)
diff --git a/tests/test_obj.py b/tests/test_obj.py
index 226781f09..c241e4363 100644
--- a/tests/test_obj.py
+++ b/tests/test_obj.py
@@ -24,7 +24,7 @@ def test_no_img(self):
         assert m.visual.uv.max() < (1 + 1e-5)
         assert m.visual.uv.min() > -1e-5
         # check to make sure it's not all zeros
-        assert m.visual.uv.ptp() > 0.5
+        assert g.np.ptp(m.visual.uv) > 0.5
         rec = g.roundtrip(m.export(file_type="obj"), file_type="obj")
         assert g.np.isclose(m.area, rec.area)
 
@@ -48,7 +48,7 @@ def test_obj_groups(self):
         # assert len(mesh.metadata['face_groups']) == len(mesh.faces)
 
         # check to make sure there is signal not just zeros
-        # assert mesh.metadata['face_groups'].ptp() > 0
+        # assert g.np.ptp(mesh.metadata['face_groups']) > 0
 
     def test_obj_negative_indices(self):
         # a wavefront file with negative indices
diff --git a/tests/test_paths.py b/tests/test_paths.py
index 61c2a44dd..9aa8ff04b 100644
--- a/tests/test_paths.py
+++ b/tests/test_paths.py
@@ -298,6 +298,26 @@ def test_section(self):
             # should be a valid Path2D
             g.check_path2D(planar)
 
+    def test_multiplane(self):
+        # check to make sure we're applying `tol_path.merge_digits` for line segments
+        vertices = g.np.array(
+            [
+                [19.402250931139097, -14.88787016674277, 64.0],
+                [20.03318396099334, -14.02738654377374, 64.0],
+                [19.402250931139097, -14.88787016674277, 0.0],
+                [20.03318396099334, -14.02738654377374, 0.0],
+                [21, -16.5, 32],
+            ]
+        )
+        faces = g.np.array(
+            [[1, 3, 0], [0, 3, 2], [1, 0, 4], [0, 2, 4], [3, 1, 4], [2, 3, 4]]
+        )
+        z_layer_centers = g.np.array([3, 3.0283334255218506])
+        m = g.trimesh.Trimesh(vertices, faces)
+        r = m.section_multiplane([0, 0, 0], [0, 0, 1], z_layer_centers)
+        assert len(r) == 2
+        assert all(i.is_closed for i in r)
+
 
 if __name__ == "__main__":
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_ply.py b/tests/test_ply.py
index a55f543fd..4cdcf0f7f 100644
--- a/tests/test_ply.py
+++ b/tests/test_ply.py
@@ -16,7 +16,7 @@ def test_ply(self):
         m = g.get_mesh("machinist.XAML")
 
         assert m.visual.kind == "face"
-        assert m.visual.face_colors.ptp(axis=0).max() > 0
+        assert g.np.ptp(m.visual.face_colors, axis=0).max() > 0
 
         export = m.export(file_type="ply")
         reconstructed = g.roundtrip(export, file_type="ply")
@@ -28,7 +28,7 @@ def test_ply(self):
         m = g.get_mesh("reference.ply")
 
         assert m.visual.kind == "vertex"
-        assert m.visual.vertex_colors.ptp(axis=0).max() > 0
+        assert g.np.ptp(m.visual.vertex_colors, axis=0).max() > 0
 
         export = m.export(file_type="ply")
         reconstructed = g.roundtrip(export, file_type="ply")
diff --git a/tests/test_points.py b/tests/test_points.py
index 1b3724eaf..68e8370cb 100644
--- a/tests/test_points.py
+++ b/tests/test_points.py
@@ -47,7 +47,9 @@ def test_pointcloud(self):
         assert hash(cloud) != initial_hash
 
         # AABB volume should be same as points
-        assert g.np.isclose(cloud.bounding_box.volume, g.np.prod(points.ptp(axis=0)))
+        assert g.np.isclose(
+            cloud.bounding_box.volume, g.np.prod(g.np.ptp(points, axis=0))
+        )
 
         # will populate all bounding primitives
         assert cloud.bounding_primitive.volume > 0.0
@@ -153,7 +155,7 @@ def test_kmeans(self, cluster_count=5, points_per_cluster=100):
         centroids, klabel = g.trimesh.points.k_means(points=clustered, k=cluster_count)
 
         # reshape to make sure all groups have the same index
-        variance = klabel.reshape((cluster_count, points_per_cluster)).ptp(axis=1)
+        variance = g.np.ptp(klabel.reshape((cluster_count, points_per_cluster)), axis=1)
 
         assert len(centroids) == cluster_count
         assert (variance == 0).all()
diff --git a/tests/test_polygons.py b/tests/test_polygons.py
index 005923da8..68c08e5bc 100644
--- a/tests/test_polygons.py
+++ b/tests/test_polygons.py
@@ -73,7 +73,7 @@ def test_sample(self):
         # try getting OBB of samples
         _T, extents = g.trimesh.path.polygons.polygon_obb(s)
         # OBB of samples should be less than diameter of circle
-        diameter = g.np.reshape(p.bounds, (2, 2)).ptp(axis=0).max()
+        diameter = g.np.ptp(g.np.reshape(p.bounds, (2, 2)), axis=0).max()
         assert (extents <= diameter).all()
 
         # test sampling with multiple bodies
diff --git a/tests/test_ray.py b/tests/test_ray.py
index 37493e955..e2c9ead5f 100644
--- a/tests/test_ray.py
+++ b/tests/test_ray.py
@@ -21,7 +21,7 @@ def test_rays(self):
         hit_any = g.np.array(hit_any, dtype=g.np.int64)
 
         for i in g.trimesh.grouping.group(g.np.unique(names, return_inverse=True)[1]):
-            broken = hit_any[i].astype(g.np.int64).ptp(axis=0).sum()
+            broken = g.np.ptp(hit_any[i].astype(g.np.int64), axis=0).sum()
             assert broken == 0
 
     def test_rps(self):
diff --git a/tests/test_remesh.py b/tests/test_remesh.py
index fe6cedda5..2988a8276 100644
--- a/tests/test_remesh.py
+++ b/tests/test_remesh.py
@@ -44,7 +44,7 @@ def test_subdivide(self):
                 assert bary.max() < (1 + epsilon)
                 assert bary.min() > -epsilon
                 # make sure it's not all zeros
-                assert bary.ptp() > epsilon
+                assert g.np.ptp(bary) > epsilon
 
             v, f = g.trimesh.remesh.subdivide(vertices=m.vertices, faces=m.faces)
 
@@ -74,7 +74,7 @@ def test_subdivide(self):
                 assert bary.max() < (1 + epsilon)
                 assert bary.min() > -epsilon
                 # make sure it's not all zeros
-                assert bary.ptp() > epsilon
+                assert g.np.ptp(bary) > epsilon
 
             check = m.subdivide_to_size(
                 max_edge=m.extents.sum(), max_iter=1, return_index=False
@@ -201,8 +201,8 @@ def test_uv(self):
         sv = s.visual.uv[s.faces]
         # both subdivided and original should have faces
         # that don't vary wildly
-        assert ov.ptp(axis=1).mean(axis=0).max() < 0.1
-        assert sv.ptp(axis=1).mean(axis=0).max() < 0.1
+        assert g.np.ptp(ov, axis=1).mean(axis=0).max() < 0.1
+        assert g.np.ptp(sv, axis=1).mean(axis=0).max() < 0.1
 
         max_edge = m.scale / 50
         s = m.subdivide_to_size(max_edge=max_edge)
@@ -220,8 +220,8 @@ def test_uv(self):
         sv = s.visual.uv[s.faces]
         # both subdivided and original should have faces
         # that don't vary wildly
-        assert ov.ptp(axis=1).mean(axis=0).max() < 0.1
-        assert sv.ptp(axis=1).mean(axis=0).max() < 0.1
+        assert g.np.ptp(ov, axis=1).mean(axis=0).max() < 0.1
+        assert g.np.ptp(sv, axis=1).mean(axis=0).max() < 0.1
 
     def test_max_iter(self):
         m = g.trimesh.creation.box()
diff --git a/tests/test_scene.py b/tests/test_scene.py
index 03473e6d7..dd6f56356 100644
--- a/tests/test_scene.py
+++ b/tests/test_scene.py
@@ -1,3 +1,5 @@
+import numpy as np
+
 try:
     from . import generic as g
 except BaseException:
@@ -102,6 +104,31 @@ def test_scene(self):
                 # make sure explode doesn't crash
                 s.explode()
 
+    def test_cam_gltf(self):
+        # Test that the camera is stored and loaded successfully into a Scene from a gltf.
+        cam = g.trimesh.scene.cameras.Camera(fov=[60, 90], name="cam1")
+        box = g.trimesh.creation.box(extents=[1, 2, 3])
+        scene = g.trimesh.Scene(
+            geometry=[box],
+            camera=cam,
+            camera_transform=np.array(
+                [[0, 1, 0, -1], [1, 0, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
+            ),
+        )
+        with g.TemporaryDirectory() as d:
+            # exports by path allow files to be written
+            path = g.os.path.join(d, "tmp.glb")
+            scene.export(path)
+            r = g.trimesh.load(path, force="scene")
+
+            # ensure no added nodes
+            assert set(r.graph.nodes) == {"world", "geometry_0", "cam1"}
+            # ensure same camera parameters and extrinsics
+            assert (r.camera_transform == scene.camera_transform).all()
+            assert r.camera.name == cam.name
+            assert (r.camera.fov == cam.fov).all()
+            assert r.camera.z_near == cam.z_near
+
     def test_scaling(self):
         # Test the scaling of scenes including unit conversion.
 
diff --git a/tests/test_scenegraph.py b/tests/test_scenegraph.py
index b5556a316..03dc9ec37 100644
--- a/tests/test_scenegraph.py
+++ b/tests/test_scenegraph.py
@@ -232,7 +232,7 @@ def test_shortest_path(self):
         # 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[queries.ptp(axis=1) > 0]
+        queries = queries[g.np.ptp(queries, axis=1) > 0]
 
         # now run our shortest path algorithm in a profiler
         with g.Profiler() as P:
diff --git a/tests/test_section.py b/tests/test_section.py
index 20c6cb040..aad45ef2c 100644
--- a/tests/test_section.py
+++ b/tests/test_section.py
@@ -99,8 +99,8 @@ def test_section(self):
                 back_3D.apply_transform(base_inv)
 
                 # make sure all vertices have constant Z
-                assert back_3D.vertices[:, 2].ptp() < 1e-8
-                assert sections_3D[index].vertices[:, 2].ptp() < 1e-8
+                assert g.np.ptp(back_3D.vertices[:, 2]) < 1e-8
+                assert g.np.ptp(sections_3D[index].vertices[:, 2]) < 1e-8
 
                 # make sure reconstructed 3D section is at right height
                 assert g.np.isclose(
diff --git a/tests/test_segments.py b/tests/test_segments.py
index 86f44e596..c210d7681 100644
--- a/tests/test_segments.py
+++ b/tests/test_segments.py
@@ -68,7 +68,7 @@ def test_colinear(self):
         for pair in n:
             val = seg[pair]
             close = g.np.append(
-                (val[0] - val[1]).ptp(axis=1), (val[0] - val[1][::-1]).ptp(axis=1)
+                g.np.ptp(val[0] - val[1], axis=1), g.np.ptp(val[0] - val[1][::-1], axis=1)
             ).min()
             assert close < epsilon
 
diff --git a/tests/test_sweep.py b/tests/test_sweep.py
index 402f8a2d6..05c18f333 100644
--- a/tests/test_sweep.py
+++ b/tests/test_sweep.py
@@ -47,7 +47,7 @@ def test_simple_extrude(height=10):
     b = sweep_polygon(circle, path)
 
     # should be a straight extrude along Z
-    expected = np.append(np.reshape(circle.bounds, (2, 2)).ptp(axis=0), height)
+    expected = np.append(np.ptp(np.reshape(circle.bounds, (2, 2)), axis=0), height)
     assert np.allclose(expected, b.extents)
 
     # should be well constructed
diff --git a/tests/test_voxel.py b/tests/test_voxel.py
index fe172f39d..db3893633 100644
--- a/tests/test_voxel.py
+++ b/tests/test_voxel.py
@@ -90,7 +90,7 @@ def test_marching_points(self):
         # get some points on the surface of an icosahedron
         points = g.trimesh.creation.icosahedron().sample(1000)
         # make the pitch proportional to scale
-        pitch = points.ptp(axis=0).min() / 10
+        pitch = g.np.ptp(points, axis=0).min() / 10
         # run marching cubes
         mesh = g.trimesh.voxel.ops.points_to_marching_cubes(points=points, pitch=pitch)