Repository: mikedh/trimesh (language: python)

Issue:
Typing issue for is_empty
Recent releases cause mypy to fail when typing `is_empty` - https://github.com/mikedh/trimesh/blob/94b8ceacb3db0aa96fdebe721f243af4d01c2b5d/trimesh/parent.py#L42-L43

The abstract method is typed as a method but the implementation is as a property:
https://github.com/mikedh/trimesh/blob/94b8ceacb3db0aa96fdebe721f243af4d01c2b5d/trimesh/base.py#L1637-L1638

This means mypy fails with `truthy-function`


Discussion / hints:

Fix patch:
diff --git a/Dockerfile b/Dockerfile
index cc93105a3..f994ef1a1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -69,23 +69,18 @@ USER user
 # install things like pytest
 RUN pip install -e .[all]
 
-# check formatting
-RUN ruff trimesh
-
-
-
-## TODO : get typeguard to pass on more/all of the codebase
-## this is running on a very arbitrary subset right now!
-RUN pytest \
-    --typeguard-packages=trimesh.scene,trimesh.base \
-    -p no:ALL_DEPENDENCIES \
-    -p no:INCLUDE_RENDERING \
-    -p no:cacheprovider tests/test_s*
+# check for lint problems
+RUN ruff check trimesh
 
+# run a limited array of static type checks
+# TODO : get this to pass on base
+RUN pyright trimesh/base.py || true
 
 # run pytest wrapped with xvfb for simple viewer tests
-RUN xvfb-run pytest \
+# print more columns so the short summary is usable
+RUN COLUMNS=240 xvfb-run pytest \
     --cov=trimesh \
+    --beartype-packages=trimesh \
     -p no:ALL_DEPENDENCIES \
     -p no:INCLUDE_RENDERING \
     -p no:cacheprovider tests
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 9274833d5..53e871e52 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,13 +1,13 @@
-pypandoc==1.12
+pypandoc==1.13
 recommonmark==0.7.1
 jupyter==1.0.0
 
 # get sphinx version range from furo install
-furo==2023.9.10
+furo==2024.1.29
 myst-parser==2.0.0
-pyopenssl==23.3.0
+pyopenssl==24.1.0
 autodocsumm==0.2.12
 jinja2==3.1.3
-matplotlib==3.8.2
-nbconvert==7.14.1
+matplotlib==3.8.3
+nbconvert==7.16.2
 
diff --git a/pyproject.toml b/pyproject.toml
index bc7eb0af6..ed64fc06e 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.2.0"
+version = "4.2.1"
 authors = [{name = "Michael Dawson-Haggerty", email = "mikedh@kerfed.com"}]
 license = {file = "LICENSE.md"}
 description = "Import, export, process, analyze and view triangular meshes."
@@ -26,7 +26,8 @@ classifiers = [
     "Topic :: Multimedia :: Graphics :: 3D Modeling"
 ]
 urls = {Homepage = "https://github.com/mikedh/trimesh"}
-dependencies = ["numpy"]
+
+dependencies = ["numpy>=1.20"]
 
 [project.readme]
 file = "README.md"
@@ -91,7 +92,7 @@ recommend = [
     "python-fcl", # do collision checks
     "openctm", # load `CTM` compressed models
     "cascadio", # load `STEP` files
-    "manifold3d>=2.3.0"
+    "manifold3d>=2.3.0" # do boolean operations
 ]
 
 # this is the list of everything that is ever added anywhere
@@ -99,14 +100,14 @@ recommend = [
 test = [
     "pytest-cov",
     "coveralls",
-    "mypy",
+    "pyright",
     "ezdxf",
     "pytest",
     "pymeshlab",
     "pyinstrument",
     "matplotlib",
     "ruff",
-    "typeguard>=4.1.2"
+    "pytest-beartype; python_version>='3.10'"
 ]
 
 # requires pip >= 21.2
@@ -150,3 +151,10 @@ flake8-implicit-str-concat = {"allow-multiline" = false}
 [tool.codespell]
 skip = "*.js*,./docs/built/*,./docs/generate/*,./models*,*.toml"
 ignore-words-list = "nd,coo,whats,bu,childs,mis,filetests"
+
+[tool.mypy]
+python_version = "3.8"
+ignore_missing_imports = true
+disallow_untyped_defs = false
+disallow_untyped_calls = false
+disable_error_code = ["method-assign"]
\ No newline at end of file
diff --git a/trimesh/base.py b/trimesh/base.py
index d6a7c716b..ce6637325 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -36,13 +36,14 @@
     units,
     util,
 )
+from .caching import TrackedArray
 from .constants import log, tol
 from .exceptions import ExceptionWrapper
 from .exchange.export import export_mesh
 from .parent import Geometry3D
 from .scene import Scene
 from .triangles import MassProperties
-from .typed import Any, ArrayLike, Dict, List, NDArray, Optional, Tuple, Union
+from .typed import Any, ArrayLike, Dict, List, NDArray, Number, Optional, Sequence, Union
 from .visual import ColorVisuals, TextureVisuals, create_visual
 
 try:
@@ -57,7 +58,7 @@
     Graph = ExceptionWrapper(E)
 
 try:
-    from rtree import Index
+    from rtree.index import Index
 except BaseException as E:
     Index = ExceptionWrapper(E)
 
@@ -73,12 +74,12 @@ def __init__(
         self,
         vertices: Optional[ArrayLike] = None,
         faces: Optional[ArrayLike] = None,
-        face_normals: Optional[NDArray[float64]] = None,
-        vertex_normals: Optional[NDArray[float64]] = None,
-        face_colors: Optional[NDArray[float64]] = None,
-        vertex_colors: Optional[NDArray[float64]] = None,
-        face_attributes: Optional[Dict[str, NDArray]] = None,
-        vertex_attributes: Optional[Dict[str, NDArray]] = None,
+        face_normals: Optional[ArrayLike] = None,
+        vertex_normals: Optional[ArrayLike] = None,
+        face_colors: Optional[ArrayLike] = None,
+        vertex_colors: Optional[ArrayLike] = None,
+        face_attributes: Optional[Dict[str, ArrayLike]] = None,
+        vertex_attributes: Optional[Dict[str, ArrayLike]] = None,
         metadata: Optional[Dict[str, Any]] = None,
         process: bool = True,
         validate: bool = False,
@@ -122,9 +123,6 @@ def __init__(
           Assigned to self.visual
         """
 
-        if initial_cache is None:
-            initial_cache = {}
-
         # self._data stores information about the mesh which
         # CANNOT be regenerated.
         # in the base class all that is stored here is vertex and
@@ -139,15 +137,16 @@ def __init__(
         # In order to maintain consistency
         # the cache is cleared when self._data.__hash__() changes
         self._cache = caching.Cache(id_function=self._data.__hash__, force_immutable=True)
-        self._cache.update(initial_cache)
+        if initial_cache is not None:
+            self._cache.update(initial_cache)
 
         # check for None only to avoid warning messages in subclasses
-        if vertices is not None:
-            # (n, 3) float, set of vertices
-            self.vertices = vertices
-        if faces is not None:
-            # (m, 3) int of triangle faces, references self.vertices
-            self.faces = faces
+
+        # (n, 3) float array of vertices
+        self.vertices = vertices
+
+        # (m, 3) int of triangle faces that references self.vertices
+        self.faces = faces
 
         # hold visual information about the mesh (vertex and face colors)
         if visual is None:
@@ -185,9 +184,8 @@ def __init__(
         # convenience class for nearest point queries
         self.nearest = proximity.ProximityQuery(self)
 
-        # store metadata about the mesh in a dictionary
-        self.metadata = {}
         # update the mesh metadata with passed metadata
+        self.metadata = {}
         if isinstance(metadata, dict):
             self.metadata.update(metadata)
         elif metadata is not None:
@@ -288,7 +286,7 @@ def mutable(self, value: bool) -> None:
         self._data.mutable = value
 
     @property
-    def faces(self) -> NDArray[int64]:
+    def faces(self) -> TrackedArray:
         """
         The faces of the mesh.
 
@@ -302,10 +300,10 @@ def faces(self) -> NDArray[int64]:
         faces : (n, 3) int64
           References for `self.vertices` for triangles.
         """
-        return self._data.get("faces", np.empty(shape=(0, 3), dtype=int64))
+        return self._data["faces"]
 
     @faces.setter
-    def faces(self, values: Union[List[List[int]], NDArray[int64]]):
+    def faces(self, values: Optional[ArrayLike]) -> None:
         """
         Set the vertex indexes that make up triangular faces.
 
@@ -314,15 +312,17 @@ def faces(self, values: Union[List[List[int]], NDArray[int64]]):
         values : (n, 3) int64
           Indexes of self.vertices
         """
-        if values is None or len(values) == 0:
-            return self._data.data.pop("faces", None)
-        if not (isinstance(values, np.ndarray) and values.dtype == int64):
+        if values is None:
+            # if passed none store an empty array
+            values = np.empty(shape=(0, 3), dtype=int64)
+        else:
             values = np.asanyarray(values, dtype=int64)
 
         # automatically triangulate quad faces
         if len(values.shape) == 2 and values.shape[1] != 3:
             log.info("triangulating faces")
             values = geometry.triangulate_quads(values)
+
         self._data["faces"] = values
 
     @caching.cache_decorator
@@ -337,8 +337,7 @@ def faces_sparse(self) -> coo_matrix:
           dtype : bool
           shape : (len(self.vertices), len(self.faces))
         """
-        sparse = geometry.index_sparse(columns=len(self.vertices), indices=self.faces)
-        return sparse
+        return geometry.index_sparse(columns=len(self.vertices), indices=self.faces)
 
     @property
     def face_normals(self) -> NDArray[float64]:
@@ -363,7 +362,7 @@ def face_normals(self) -> NDArray[float64]:
 
         # if we have no faces exit early
         if faces is None or len(faces) == 0:
-            return np.array([], dtype=int64).reshape((0, 3))
+            return np.array([], dtype=float64).reshape((0, 3))
 
         # if the shape of cached normals equals the shape of faces return
         if np.shape(cached) == np.shape(faces):
@@ -393,14 +392,14 @@ def face_normals(self) -> NDArray[float64]:
         return padded
 
     @face_normals.setter
-    def face_normals(self, values: NDArray[float64]) -> None:
+    def face_normals(self, values: Optional[ArrayLike]) -> None:
         """
         Assign values to face normals.
 
         Parameters
         -------------
         values : (len(self.faces), 3) float
-          Unit face normals
+          Unit face normals. If None will clear existing normals.
         """
         # if nothing passed exit
         if values is None:
@@ -433,7 +432,7 @@ def face_normals(self, values: NDArray[float64]) -> None:
         self._cache["face_normals"] = values
 
     @property
-    def vertices(self) -> NDArray[float64]:
+    def vertices(self) -> TrackedArray:
         """
         The vertices of the mesh.
 
@@ -447,10 +446,11 @@ def vertices(self) -> NDArray[float64]:
         vertices : (n, 3) float
           Points in cartesian space referenced by self.faces
         """
-        return self._data.get("vertices", np.empty(shape=(0, 3), dtype=float64))
+        # get vertices if already stored
+        return self._data["vertices"]
 
     @vertices.setter
-    def vertices(self, values: ArrayLike):
+    def vertices(self, values: Optional[ArrayLike]):
         """
         Assign vertex values to the mesh.
 
@@ -459,8 +459,9 @@ def vertices(self, values: ArrayLike):
         values : (n, 3) float
           Points in space
         """
-        if values is None or len(values) == 0:
-            return self._data.data.pop("vertices", None)
+        if values is None:
+            # remove any stored data and store an empty array
+            values = np.empty(shape=(0, 3), dtype=float64)
         self._data["vertices"] = np.asanyarray(values, order="C", dtype=float64)
 
     @caching.cache_decorator
@@ -481,13 +482,12 @@ def vertex_normals(self) -> NDArray[float64]:
         """
         # make sure we have faces_sparse
         assert hasattr(self.faces_sparse, "dot")
-        vertex_normals = geometry.weighted_vertex_normals(
+        return geometry.weighted_vertex_normals(
             vertex_count=len(self.vertices),
             faces=self.faces,
             face_normals=self.face_normals,
             face_angles=self.face_angles,
         )
-        return vertex_normals
 
     @vertex_normals.setter
     def vertex_normals(self, values: ArrayLike) -> None:
@@ -601,7 +601,7 @@ def center_mass(self) -> NDArray[float64]:
         return self.mass_properties.center_mass
 
     @center_mass.setter
-    def center_mass(self, value: NDArray[float64]) -> None:
+    def center_mass(self, value: ArrayLike) -> None:
         """
         Override the point in space which is the center of mass and volume.
 
@@ -626,10 +626,10 @@ def density(self) -> float:
         density
           The density of the primitive.
         """
-        return self.mass_properties.density
+        return float(self.mass_properties.density)
 
     @density.setter
-    def density(self, value: float) -> None:
+    def density(self, value: Number) -> None:
         """
         Set the density of the primitive.
 
@@ -687,7 +687,7 @@ def moment_inertia(self) -> NDArray[float64]:
         """
         return self.mass_properties.inertia
 
-    def moment_inertia_frame(self, transform: NDArray[float64]) -> NDArray[float64]:
+    def moment_inertia_frame(self, transform: ArrayLike) -> NDArray[float64]:
         """
         Get the moment of inertia of this mesh with respect to
         an arbitrary frame, versus with respect to the center
@@ -801,7 +801,7 @@ def symmetry(self) -> Optional[str]:
         return symmetry
 
     @property
-    def symmetry_axis(self) -> NDArray[float64]:
+    def symmetry_axis(self) -> Optional[NDArray[float64]]:
         """
         If a mesh has rotational symmetry, return the axis.
 
@@ -810,11 +810,12 @@ def symmetry_axis(self) -> NDArray[float64]:
         axis : (3, ) float
           Axis around which a 2D profile was revolved to create this mesh.
         """
-        if self.symmetry is not None:
-            return self._cache["symmetry_axis"]
+        if self.symmetry is None:
+            return None
+        return self._cache["symmetry_axis"]
 
     @property
-    def symmetry_section(self) -> NDArray[float64]:
+    def symmetry_section(self) -> Optional[NDArray[float64]]:
         """
         If a mesh has rotational symmetry return the two
         vectors which make up a section coordinate frame.
@@ -824,8 +825,9 @@ def symmetry_section(self) -> NDArray[float64]:
         section : (2, 3) float
           Vectors to take a section along
         """
-        if self.symmetry is not None:
-            return self._cache["symmetry_section"]
+        if self.symmetry is None:
+            return None
+        return self._cache["symmetry_section"]
 
     @caching.cache_decorator
     def triangles(self) -> NDArray[float64]:
@@ -1062,13 +1064,12 @@ def euler_number(self) -> int:
         euler_number : int
           Topological invariant
         """
-        euler = int(
+        return int(
             self.referenced_vertices.sum() - len(self.edges_unique) + len(self.faces)
         )
-        return euler
 
     @caching.cache_decorator
-    def referenced_vertices(self) -> NDArray[bool]:
+    def referenced_vertices(self) -> NDArray[np.bool_]:
         """
         Which vertices in the current mesh are referenced by a face.
 
@@ -1081,25 +1082,6 @@ def referenced_vertices(self) -> NDArray[bool]:
         referenced[self.faces] = True
         return referenced
 
-    @property
-    def units(self) -> Optional[str]:
-        """
-        Definition of units for the mesh.
-
-        Returns
-        ----------
-        units : str
-          Unit system mesh is in, or None if not defined
-        """
-        return self.metadata.get("units", None)
-
-    @units.setter
-    def units(self, value: str) -> None:
-        """
-        Define the units of the current mesh.
-        """
-        self.metadata["units"] = str(value).lower()
-
     def convert_units(self, desired: str, guess: bool = False) -> "Trimesh":
         """
         Convert the units of the mesh into a specified unit.
@@ -1290,7 +1272,7 @@ def remove_infinite_values(self) -> None:
             vertex_mask = np.isfinite(self.vertices).all(axis=1)
             self.update_vertices(vertex_mask)
 
-    def unique_faces(self) -> NDArray[bool]:
+    def unique_faces(self) -> NDArray[np.bool_]:
         """
         On the current mesh find which faces are unique.
 
@@ -1456,7 +1438,7 @@ def face_adjacency_projections(self) -> NDArray[float64]:
         return projections
 
     @caching.cache_decorator
-    def face_adjacency_convex(self) -> NDArray[bool]:
+    def face_adjacency_convex(self) -> NDArray[np.bool_]:
         """
         Return faces which are adjacent and locally convex.
 
@@ -1689,7 +1671,7 @@ def remove_degenerate_faces(self, height: float = tol.merge) -> None:
         )
         self.update_faces(self.nondegenerate_faces(height=height))
 
-    def nondegenerate_faces(self, height: float = tol.merge) -> NDArray[bool]:
+    def nondegenerate_faces(self, height: float = tol.merge) -> NDArray[np.bool_]:
         """
         Identify degenerate faces (faces without 3 unique vertex indices)
         in the current mesh.
@@ -1806,7 +1788,7 @@ def facets_boundary(self) -> List[NDArray[int64]]:
         return edges_boundary
 
     @caching.cache_decorator
-    def facets_on_hull(self) -> NDArray[bool]:
+    def facets_on_hull(self) -> NDArray[np.bool_]:
         """
         Find which facets of the mesh are on the convex hull.
 
@@ -1868,7 +1850,7 @@ def fill_holes(self) -> bool:
         """
         return repair.fill_holes(self)
 
-    def register(self, other: Geometry3D, **kwargs):
+    def register(self, other: Union[Geometry3D, NDArray], **kwargs):
         """
         Align a mesh with another mesh or a PointCloud using
         the principal axes of inertia as a starting point which
@@ -1876,8 +1858,6 @@ def register(self, other: Geometry3D, **kwargs):
 
         Parameters
         ------------
-        mesh : trimesh.Trimesh object
-          Mesh to align with other
         other : trimesh.Trimesh or (n, 3) float
           Mesh or points in space
         samples : int
@@ -1955,14 +1935,14 @@ def compute_stable_poses(
             threshold=threshold,
         )
 
-    def subdivide(self, face_index: None = None) -> "Trimesh":
+    def subdivide(self, face_index: Optional[ArrayLike] = None) -> "Trimesh":
         """
-        Subdivide a mesh, with each subdivided face replaced with four
-        smaller faces.
+        Subdivide a mesh, with each subdivided face replaced
+        with four smaller faces.
 
         Parameters
         ------------
-        face_index: (m, ) int or None
+        face_index : (m, ) int or None
           If None all faces of mesh will be subdivided
           If (m, ) int array of indices: only specified faces will be
           subdivided. Note that in this case the mesh will generally
@@ -1971,8 +1951,6 @@ def subdivide(self, face_index: None = None) -> "Trimesh":
           and an additional postprocessing step will be required to
           make resulting mesh watertight
         """
-        # subdivide vertex attributes
-        vertex_attributes = {}
         visual = None
         if hasattr(self.visual, "uv") and np.shape(self.visual.uv) == (
             len(self.vertices),
@@ -1983,7 +1961,7 @@ def subdivide(self, face_index: None = None) -> "Trimesh":
                 vertices=np.hstack((self.vertices, self.visual.uv)),
                 faces=self.faces,
                 face_index=face_index,
-                vertex_attributes=vertex_attributes,
+                vertex_attributes=self.vertex_attributes,
             )
 
             # get a copy of the current visuals
@@ -1998,7 +1976,7 @@ def subdivide(self, face_index: None = None) -> "Trimesh":
                 vertices=self.vertices,
                 faces=self.faces,
                 face_index=face_index,
-                vertex_attributes=vertex_attributes,
+                vertex_attributes=self.vertex_attributes,
             )
 
         # create a new mesh
@@ -2434,7 +2412,7 @@ def unmerge_vertices(self) -> None:
         # keep face normals as the haven't changed
         self._cache.clear(exclude=["face_normals"])
 
-    def apply_transform(self, matrix: NDArray[float64]) -> "Trimesh":
+    def apply_transform(self, matrix: ArrayLike) -> "Trimesh":
         """
         Transform mesh by a homogeneous transformation matrix.
 
@@ -2767,7 +2745,9 @@ def show(self, **kwargs):
         scene = self.scene()
         return scene.show(**kwargs)
 
-    def submesh(self, faces_sequence: List[NDArray[int64]], **kwargs):
+    def submesh(
+        self, faces_sequence: Sequence[ArrayLike], **kwargs
+    ) -> Union["Trimesh", List["Trimesh"]]:
         """
         Return a subset of the mesh.
 
@@ -2819,12 +2799,7 @@ def export(
         file_obj=None,
         file_type: Optional[str] = None,
         **kwargs,
-    ) -> Union[
-        Dict[str, Union[Dict[str, str], List[List[int]], List[List[float]]]],
-        str,
-        bytes,
-        Dict[str, Union[Dict[str, str], Dict[str, Union[str, Tuple[int, int]]]]],
-    ]:
+    ):
         """
         Export the current mesh to a file object.
         If file_obj is a filename, file will be written there.
@@ -2983,7 +2958,7 @@ def intersection(
             **kwargs,
         )
 
-    def contains(self, points: ArrayLike) -> NDArray[bool]:
+    def contains(self, points: ArrayLike) -> NDArray[np.bool_]:
         """
         Given an array of points determine whether or not they
         are inside the mesh. This raises an error if called on a
diff --git a/trimesh/constants.py b/trimesh/constants.py
index 07acc2c36..28d4cdcf1 100644
--- a/trimesh/constants.py
+++ b/trimesh/constants.py
@@ -90,14 +90,14 @@ class TolerancePath:
     merge: float = 1e-5
     planar: float = 1e-5
     seg_frac: float = 0.125
-    seg_angle: float = np.radians(50)
-    seg_angle_min: float = np.radians(1)
+    seg_angle: float = float(np.radians(50))
+    seg_angle_min: float = float(np.radians(1))
     seg_angle_frac: float = 0.5
     aspect_frac: float = 0.1
     radius_frac: float = 0.02
     radius_min: float = 1e-4
     radius_max: float = 50.0
-    tangent: float = np.radians(20)
+    tangent: float = float(np.radians(20))
 
     strict: bool = False
 
@@ -122,8 +122,8 @@ class ResolutionPath:
 
     seg_frac: float = 0.05
     seg_angle: float = 0.08
-    max_sections: float = 500
-    min_sections: float = 20
+    max_sections: float = 500.0
+    min_sections: float = 20.0
     export: str = "0.10f"
 
 
diff --git a/trimesh/creation.py b/trimesh/creation.py
index 60e218c30..e7afcec23 100644
--- a/trimesh/creation.py
+++ b/trimesh/creation.py
@@ -15,7 +15,7 @@
 from .base import Trimesh
 from .constants import log, tol
 from .geometry import align_vectors, faces_to_edges, plane_transform
-from .typed import ArrayLike, Dict, NDArray, Optional, float64
+from .typed import ArrayLike, Dict, Integer, Number, Optional
 
 try:
     # shapely is a soft dependency
@@ -35,11 +35,11 @@
 
 def revolve(
     linestring: ArrayLike,
-    angle: Optional[float] = None,
-    sections: Optional[int] = None,
-    transform: Optional[NDArray] = None,
+    angle: Optional[Number] = None,
+    sections: Optional[Integer] = None,
+    transform: Optional[ArrayLike] = None,
     **kwargs,
-):
+) -> Trimesh:
     """
     Revolve a 2D line string around the 2D Y axis, with a result with
     the 2D Y axis pointing along the 3D Z axis.
@@ -173,8 +173,8 @@ def revolve(
 
 
 def extrude_polygon(
-    polygon: "Polygon", height: float, transform: Optional[NDArray] = None, **kwargs
-):
+    polygon: "Polygon", height: Number, transform: Optional[ArrayLike] = None, **kwargs
+) -> Trimesh:
     """
     Extrude a 2D shapely polygon into a 3D mesh
 
@@ -206,7 +206,7 @@ def extrude_polygon(
 
 
 def sweep_polygon(
-    polygon: "Polygon", path: ArrayLike, angles: Optional[NDArray] = None, **kwargs
+    polygon: "Polygon", path: ArrayLike, angles: Optional[ArrayLike] = None, **kwargs
 ):
     """
     Extrude a 2D shapely polygon into a 3D mesh along an
@@ -315,10 +315,10 @@ def sweep_polygon(
 def extrude_triangulation(
     vertices: ArrayLike,
     faces: ArrayLike,
-    height: float,
-    transform: Optional[NDArray] = None,
+    height: Number,
+    transform: Optional[ArrayLike] = None,
     **kwargs,
-):
+) -> Trimesh:
     """
     Extrude a 2D triangulation into a watertight mesh.
 
@@ -404,10 +404,13 @@ def extrude_triangulation(
     return mesh
 
 
-def triangulate_polygon(polygon: Polygon, triangle_args=None, engine=None, **kwargs):
+def triangulate_polygon(
+    polygon, triangle_args: Optional[str] = None, engine: Optional[str] = None, **kwargs
+):
     """
     Given a shapely polygon create a triangulation using a
-    python interface to `triangle.c` or mapbox-earcut.
+    python interface to the permissively licensed `mapbox-earcut`
+    or the more robust `triangle.c`.
     > pip install triangle
     > pip install mapbox_earcut
 
@@ -416,9 +419,9 @@ def triangulate_polygon(polygon: Polygon, triangle_args=None, engine=None, **kwa
     polygon : Shapely.geometry.Polygon
         Polygon object to be triangulated.
     triangle_args : str or None
-        Passed to triangle.triangulate i.e: 'p', 'pq30'
+        Passed to triangle.triangulate i.e: 'p', 'pq30', 'pY'="don't insert vert"
     engine : None or str
-      Any value other than 'earcut' will use `triangle`
+      None or 'earcut' will use earcut, 'triangle' will use triangle
 
     Returns
     --------------
@@ -467,7 +470,7 @@ def triangulate_polygon(polygon: Polygon, triangle_args=None, engine=None, **kwa
         raise ValueError("no valid triangulation engine!")
 
 
-def _polygon_to_kwargs(polygon: Polygon) -> Dict:
+def _polygon_to_kwargs(polygon) -> Dict:
     """
     Given a shapely polygon generate the data to pass to
     the triangle mesh generator
@@ -559,13 +562,18 @@ def add_boundary(boundary, start):
     return result
 
 
-def box(extents=None, transform: Optional[NDArray] = None, bounds=None, **kwargs):
+def box(
+    extents: Optional[ArrayLike] = None,
+    transform: Optional[ArrayLike] = None,
+    bounds: Optional[ArrayLike] = None,
+    **kwargs,
+):
     """
     Return a cuboid.
 
     Parameters
     ------------
-    extents : float, or (3,) float
+    extents : (3,) float
       Edge lengths
     transform: (4, 4) float
       Transformation matrix
@@ -703,7 +711,7 @@ def box(extents=None, transform: Optional[NDArray] = None, bounds=None, **kwargs
     return box
 
 
-def icosahedron(**kwargs):
+def icosahedron(**kwargs) -> Trimesh:
     """
     Create an icosahedron, one of the platonic solids which is has 20 faces.
 
@@ -827,7 +835,7 @@ def icosahedron(**kwargs):
     )
 
 
-def icosphere(subdivisions: int = 3, radius: float = 1.0, **kwargs):
+def icosphere(subdivisions: Integer = 3, radius: Number = 1.0, **kwargs):
     """
     Create an isophere centered at the origin.
 
@@ -877,11 +885,11 @@ def icosphere(subdivisions: int = 3, radius: float = 1.0, **kwargs):
 
 
 def uv_sphere(
-    radius: float = 1.0,
+    radius: Number = 1.0,
     count: Optional[ArrayLike] = None,
-    transform: Optional[NDArray] = None,
+    transform: Optional[ArrayLike] = None,
     **kwargs,
-):
+) -> Trimesh:
     """
     Create a UV sphere (latitude + longitude) centered at the
     origin. Roughly one order of magnitude faster than an
@@ -926,10 +934,10 @@ def uv_sphere(
 
 
 def capsule(
-    height: float = 1.0,
-    radius: float = 1.0,
+    height: Number = 1.0,
+    radius: Number = 1.0,
     count: Optional[ArrayLike] = None,
-    transform: Optional[NDArray] = None,
+    transform: Optional[ArrayLike] = None,
 ):
     """
     Create a mesh of a capsule, or a cylinder with hemispheric ends.
@@ -979,12 +987,12 @@ def capsule(
 
 
 def cone(
-    radius: float,
-    height: float,
-    sections: Optional[int] = None,
-    transform: Optional[NDArray] = None,
+    radius: Number,
+    height: Number,
+    sections: Optional[Integer] = None,
+    transform: Optional[ArrayLike] = None,
     **kwargs,
-):
+) -> Trimesh:
     """
     Create a mesh of a cone along Z centered at the origin.
 
@@ -1020,11 +1028,11 @@ def cone(
 
 
 def cylinder(
-    radius: float,
-    height: Optional[float] = None,
-    sections: Optional[int] = None,
-    segment=None,
-    transform: Optional[NDArray] = None,
+    radius: Number,
+    height: Optional[Number] = None,
+    sections: Optional[Integer] = None,
+    segment: Optional[ArrayLike] = None,
+    transform: Optional[ArrayLike] = None,
     **kwargs,
 ):
     """
@@ -1071,12 +1079,12 @@ def cylinder(
 
 
 def annulus(
-    r_min: float,
-    r_max: float,
-    height: Optional[float] = None,
-    sections: Optional[int] = None,
-    transform: Optional[NDArray] = None,
-    segment: Optional[NDArray] = None,
+    r_min: Number,
+    r_max: Number,
+    height: Optional[Number] = None,
+    sections: Optional[Integer] = None,
+    transform: Optional[ArrayLike] = None,
+    segment: Optional[ArrayLike] = None,
     **kwargs,
 ):
     """
@@ -1143,7 +1151,7 @@ def annulus(
     return annulus
 
 
-def _segment_to_cylinder(segment: NDArray[float64]):
+def _segment_to_cylinder(segment: ArrayLike):
     """
     Convert a line segment to a transform and height for a cylinder
     or cylinder-like primitive.
@@ -1177,7 +1185,7 @@ def _segment_to_cylinder(segment: NDArray[float64]):
     return transform, height
 
 
-def random_soup(face_count: int = 100):
+def random_soup(face_count: Integer = 100):
     """
     Return random triangles as a Trimesh
 
@@ -1198,11 +1206,11 @@ def random_soup(face_count: int = 100):
 
 
 def axis(
-    origin_size: float = 0.04,
-    transform: Optional[NDArray] = None,
+    origin_size: Number = 0.04,
+    transform: Optional[ArrayLike] = None,
     origin_color: Optional[ArrayLike] = None,
-    axis_radius: Optional[float] = None,
-    axis_length: Optional[float] = None,
+    axis_radius: Optional[Number] = None,
+    axis_length: Optional[Number] = None,
 ):
     """
     Return an XYZ axis marker as a  Trimesh, which represents position
@@ -1284,7 +1292,7 @@ def axis(
 
 
 def camera_marker(
-    camera, marker_height: float = 0.4, origin_size: Optional[float] = None
+    camera, marker_height: Number = 0.4, origin_size: Optional[Number] = None
 ):
     """
     Create a visual marker for a camera object, including an axis and FOV.
@@ -1411,11 +1419,11 @@ def truncated_prisms(
 
 
 def torus(
-    major_radius: float,
-    minor_radius: float,
-    major_sections: int = 32,
-    minor_sections: int = 32,
-    transform: Optional[NDArray] = None,
+    major_radius: Number,
+    minor_radius: Number,
+    major_sections: Integer = 32,
+    minor_sections: Integer = 32,
+    transform: Optional[ArrayLike] = None,
     **kwargs,
 ):
     """Create a mesh of a torus around Z centered at the origin.
diff --git a/trimesh/decomposition.py b/trimesh/decomposition.py
index 5793ae52b..c467b0c4d 100644
--- a/trimesh/decomposition.py
+++ b/trimesh/decomposition.py
@@ -1,7 +1,7 @@
-from typing import Dict, List
-
 import numpy as np
 
+from .typed import Dict, List
+
 
 def convex_decomposition(mesh, **kwargs) -> List[Dict]:
     """
diff --git a/trimesh/exchange/cascade.py b/trimesh/exchange/cascade.py
index d343b2ef9..5e8c4cedb 100644
--- a/trimesh/exchange/cascade.py
+++ b/trimesh/exchange/cascade.py
@@ -1,7 +1,7 @@
 import os
 import tempfile
 
-from ..typed import BinaryIO, Dict, Optional
+from ..typed import BinaryIO, Dict, Number, Optional
 
 # used as an intermediate format
 from .gltf import load_glb
@@ -10,8 +10,8 @@
 def load_step(
     file_obj: BinaryIO,
     file_type,
-    tol_linear: Optional[float] = None,
-    tol_angular: Optional[float] = None,
+    tol_linear: Optional[Number] = None,
+    tol_angular: Optional[Number] = None,
     tol_relative: Optional[bool] = False,
     merge_primitives: bool = True,
     **kwargs,
diff --git a/trimesh/exchange/load.py b/trimesh/exchange/load.py
index 3daf81413..8922bb058 100644
--- a/trimesh/exchange/load.py
+++ b/trimesh/exchange/load.py
@@ -9,7 +9,7 @@
 from ..parent import Geometry
 from ..points import PointCloud
 from ..scene.scene import Scene, append_scenes
-from ..typed import List, Loadable, Optional, Union
+from ..typed import Dict, List, Loadable, Optional, Union
 from ..util import log, now
 from . import misc
 from .binvox import _binvox_loaders
@@ -71,10 +71,10 @@ def available_formats() -> set:
 def load(
     file_obj: Loadable,
     file_type: Optional[str] = None,
-    resolver: Optional[resolvers.Resolver] = None,
+    resolver: Union[resolvers.Resolver, Dict, None] = None,
     force: Optional[str] = None,
     **kwargs,
-) -> Geometry:
+) -> Union[Geometry, List[Geometry]]:
     """
     Load a mesh or vectorized path into objects like
     Trimesh, Path2D, Path3D, Scene
@@ -164,7 +164,7 @@ def load(
 def load_mesh(
     file_obj: Loadable,
     file_type: Optional[str] = None,
-    resolver: Optional[resolvers.Resolver] = None,
+    resolver: Union[resolvers.Resolver, Dict, None] = None,
     **kwargs,
 ) -> Union[Geometry, List[Geometry]]:
     """
@@ -508,7 +508,7 @@ def handle_pointcloud():
 def _parse_file_args(
     file_obj: Loadable,
     file_type: Optional[str],
-    resolver: Optional[resolvers.Resolver] = None,
+    resolver: Union[None, Dict, resolvers.Resolver] = None,
     **kwargs,
 ):
     """
diff --git a/trimesh/geometry.py b/trimesh/geometry.py
index 3c79c93cf..7392ac658 100644
--- a/trimesh/geometry.py
+++ b/trimesh/geometry.py
@@ -2,6 +2,7 @@
 
 from . import util
 from .constants import log
+from .typed import NDArray
 
 try:
     import scipy.sparse
@@ -102,7 +103,7 @@ def faces_to_edges(faces, return_index=False):
     edges : (n*3, 2) int
       Vertex indices representing edges
     """
-    faces = np.asanyarray(faces)
+    faces = np.asanyarray(faces, np.int64)
 
     # each face has three edges
     edges = faces[:, [0, 1, 1, 2, 2, 0]].reshape((-1, 2))
@@ -146,7 +147,7 @@ def vector_angle(pairs):
     return angles
 
 
-def triangulate_quads(quads, dtype=np.int64):
+def triangulate_quads(quads, dtype=np.int64) -> NDArray:
     """
     Given an array of quad faces return them as triangle faces,
     also handles pure triangles and mixed triangles and quads.
diff --git a/trimesh/graph.py b/trimesh/graph.py
index dd14d891d..22f77c1b1 100644
--- a/trimesh/graph.py
+++ b/trimesh/graph.py
@@ -16,7 +16,7 @@
 from . import exceptions, grouping, util
 from .constants import log, tol
 from .geometry import faces_to_edges
-from .typed import List, Optional
+from .typed import List, NDArray, Number, Optional, int64
 
 try:
     from scipy.sparse import coo_matrix, csgraph
@@ -107,10 +107,11 @@ def face_adjacency(faces=None, mesh=None, return_edges=False):
         adjacency_edges = edges[edge_groups[:, 0][nondegenerate]]
         assert len(adjacency_edges) == len(adjacency)
         return adjacency, adjacency_edges
+
     return adjacency
 
 
-def face_neighborhood(mesh):
+def face_neighborhood(mesh) -> NDArray[int64]:
     """
     Find faces that share a vertex i.e. 'neighbors' faces.
     Relies on the fact that an adjacency matrix at a power p
@@ -129,7 +130,9 @@ def face_neighborhood(mesh):
     TT.setdiag(0)
     TT.eliminate_zeros()
     TT = TT.tocoo()
-    neighborhood = np.concatenate((TT.row[:, None], TT.col[:, None]), axis=-1)
+    neighborhood = np.concatenate(
+        (TT.row[:, None], TT.col[:, None]), axis=-1, dtype=np.int64
+    )
     return neighborhood
 
 
@@ -757,7 +760,7 @@ def smoothed(*args, **kwargs):
 
 
 def smooth_shade(
-    mesh, angle: Optional[float] = None, facet_minarea: Optional[float] = 10.0
+    mesh, angle: Optional[Number] = None, facet_minarea: Optional[Number] = 10.0
 ):
     """
     Return a non-watertight version of the mesh which
diff --git a/trimesh/grouping.py b/trimesh/grouping.py
index 661f5a9b4..84c8934be 100644
--- a/trimesh/grouping.py
+++ b/trimesh/grouping.py
@@ -9,7 +9,7 @@
 
 from . import util
 from .constants import log, tol
-from .typed import ArrayLike, NDArray, Optional, Union
+from .typed import ArrayLike, Integer, NDArray, Optional
 
 try:
     from scipy.spatial import cKDTree
@@ -157,9 +157,7 @@ def group(values, min_len=0, max_len=np.inf):
     return groups
 
 
-def hashable_rows(
-    data: ArrayLike, digits=None
-) -> Union[NDArray[np.uint64], NDArray[np.void]]:
+def hashable_rows(data: ArrayLike, digits=None) -> NDArray:
     """
     We turn our array into integers based on the precision
     given by digits and then put them in a hashable format.
@@ -179,7 +177,7 @@ def hashable_rows(
     """
     # if there is no data return immediately
     if len(data) == 0:
-        return np.array([])
+        return np.array([], dtype=np.uint64)
 
     # get array as integer to precision we care about
     as_int = float_to_int(data, digits=digits)
@@ -223,7 +221,7 @@ def hashable_rows(
     return result
 
 
-def float_to_int(data, digits: Optional[int] = None):
+def float_to_int(data, digits: Optional[Integer] = None):
     """
     Given a numpy array of float/bool/int, return as integers.
 
diff --git a/trimesh/inertia.py b/trimesh/inertia.py
index 24bda99be..94b836f88 100644
--- a/trimesh/inertia.py
+++ b/trimesh/inertia.py
@@ -94,7 +94,7 @@ def principal_axis(inertia):
     # you could any of the following to calculate this:
     # np.linalg.svd, np.linalg.eig, np.linalg.eigh
     # moment of inertia is square symmetric matrix
-    # eigh has the best numeric precision in tests
+    # eigh has the best precision in tests
     components, vectors = np.linalg.eigh(inertia)
 
     # eigh returns them as column vectors, change them to row vectors
diff --git a/trimesh/interfaces/blender.py b/trimesh/interfaces/blender.py
index 9136920a9..906fe5ffc 100644
--- a/trimesh/interfaces/blender.py
+++ b/trimesh/interfaces/blender.py
@@ -3,14 +3,13 @@
 
 from .. import resources, util
 from ..constants import log
-from ..typed import Sequence
+from ..typed import Iterable
 from .generic import MeshScript
 
-_search_path = os.environ.get("PATH", "")
 if platform.system() == "Windows":
     # try to find Blender install on Windows
     # split existing path by delimiter
-    _search_path = [i for i in _search_path.split(";") if len(i) > 0]
+    _search_path = [i for i in os.environ.get("PATH", "").split(";") if len(i) > 0]
     for pf in [r"C:\Program Files", r"C:\Program Files (x86)"]:
         pf = os.path.join(pf, "Blender Foundation")
         if os.path.exists(pf):
@@ -19,22 +18,27 @@
                     _search_path.append(os.path.join(pf, p))
     _search_path = ";".join(set(_search_path))
     log.debug("searching for blender in: %s", _search_path)
-
-if platform.system() == "Darwin":
+elif platform.system() == "Darwin":
     # try to find Blender on Mac OSX
-    _search_path = [i for i in _search_path.split(":") if len(i) > 0]
-    _search_path.append("/Applications/blender.app/Contents/MacOS")
-    _search_path.append("/Applications/Blender.app/Contents/MacOS")
-    _search_path.append("/Applications/Blender/blender.app/Contents/MacOS")
+    _search_path = [i for i in os.environ.get("PATH", "").split(":") if len(i) > 0]
+    _search_path.extend(
+        [
+            "/Applications/blender.app/Contents/MacOS",
+            "/Applications/Blender.app/Contents/MacOS",
+            "/Applications/Blender/blender.app/Contents/MacOS",
+        ]
+    )
     _search_path = ":".join(set(_search_path))
     log.debug("searching for blender in: %s", _search_path)
+else:
+    _search_path = os.environ.get("PATH", "")
 
 _blender_executable = util.which("blender", path=_search_path)
 exists = _blender_executable is not None
 
 
 def boolean(
-    meshes: Sequence,
+    meshes: Iterable,
     operation: str = "difference",
     use_exact: bool = False,
     use_self: bool = False,
@@ -80,11 +84,12 @@ def boolean(
     with MeshScript(meshes=meshes, script=script, debug=debug) as blend:
         result = blend.run(_blender_executable + " --background --python $SCRIPT")
 
-    for m in util.make_sequence(result):
+    result = util.make_sequence(result)
+    for m in result:
         # blender returns actively incorrect face normals
         m.face_normals = None
 
-    return result
+    return util.concatenate(result)
 
 
 def unwrap(
@@ -97,7 +102,7 @@ def unwrap(
         raise ValueError("No blender available!")
 
     # get the template from our resources folder
-    template = resources.get_string("templates/blender_unwrap.py")
+    template = resources.get_string("templates/blender_unwrap.py.template")
     script = template.replace("$ANGLE_LIMIT", "%.6f" % angle_limit).replace(
         "$ISLAND_MARGIN", "%.6f" % island_margin
     )
diff --git a/trimesh/interval.py b/trimesh/interval.py
index 73782d5bf..1dbc98362 100644
--- a/trimesh/interval.py
+++ b/trimesh/interval.py
@@ -8,10 +8,10 @@
 
 import numpy as np
 
-from .typed import NDArray, float64
+from .typed import ArrayLike, NDArray, float64
 
 
-def intersection(a: NDArray[float64], b: NDArray[float64]) -> NDArray[float64]:
+def intersection(a: ArrayLike, b: NDArray[float64]) -> NDArray[float64]:
     """
     Given pairs of ranges merge them in to
     one range if they overlap.
@@ -59,7 +59,7 @@ def intersection(a: NDArray[float64], b: NDArray[float64]) -> NDArray[float64]:
     return overlap
 
 
-def union(intervals: NDArray[float64], sort: bool = True) -> NDArray[float64]:
+def union(intervals: ArrayLike, sort: bool = True) -> NDArray[float64]:
     """
     For array of multiple intervals union them all into
     the subset of intervals.
diff --git a/trimesh/parent.py b/trimesh/parent.py
index 11149eac1..45bb72b5b 100644
--- a/trimesh/parent.py
+++ b/trimesh/parent.py
@@ -13,6 +13,7 @@
 from . import transformations as tf
 from .caching import cache_decorator
 from .constants import tol
+from .typed import Dict, Optional
 from .util import ABC
 
 
@@ -25,6 +26,9 @@ class Geometry(ABC):
     those methods.
     """
 
+    # geometry should have a dict to store loose metadata
+    metadata: Dict
+
     @property
     @abc.abstractmethod
     def bounds(self):
@@ -39,6 +43,7 @@ def extents(self):
     def apply_transform(self, matrix):
         pass
 
+    @property
     @abc.abstractmethod
     def is_empty(self) -> bool:
         pass
@@ -181,6 +186,25 @@ def scale(self) -> float:
 
         return scale
 
+    @property
+    def units(self) -> Optional[str]:
+        """
+        Definition of units for the mesh.
+
+        Returns
+        ----------
+        units : str
+          Unit system mesh is in, or None if not defined
+        """
+        return self.metadata.get("units", None)
+
+    @units.setter
+    def units(self, value: str) -> None:
+        """
+        Define the units of the current mesh.
+        """
+        self.metadata["units"] = str(value).lower().strip()
+
 
 class Geometry3D(Geometry):
     """
diff --git a/trimesh/path/arc.py b/trimesh/path/arc.py
index fff7c1a59..5ac765e62 100644
--- a/trimesh/path/arc.py
+++ b/trimesh/path/arc.py
@@ -6,7 +6,7 @@
 from ..constants import log
 from ..constants import res_path as res
 from ..constants import tol_path as tol
-from ..typed import ArrayLike, NDArray, Optional, float64
+from ..typed import ArrayLike, NDArray, Number, Optional, float64
 
 # floating point zero
 _TOL_ZERO = 1e-12
@@ -28,7 +28,7 @@ class ArcInfo:
     angles: Optional[NDArray[float64]] = None
 
     # what is the angular span of this circular arc.
-    span: Optional[float] = None
+    span: Optional[Number] = None
 
     def __getitem__(self, item):
         # add for backwards compatibility
diff --git a/trimesh/path/exchange/svg_io.py b/trimesh/path/exchange/svg_io.py
index 1c0cd874d..0e207a8ab 100644
--- a/trimesh/path/exchange/svg_io.py
+++ b/trimesh/path/exchange/svg_io.py
@@ -670,7 +670,7 @@ def _deep_same(original, other):
         assert original == other
         return
     elif isinstance(original, (float, int, np.ndarray)):
-        # for numeric classes use numpy magic comparison
+        # for Number classes use numpy magic comparison
         # which includes an epsilon for floating point
         assert np.allclose(original, other)
         return
diff --git a/trimesh/path/packing.py b/trimesh/path/packing.py
index 6c9db7d5f..716a3a43a 100644
--- a/trimesh/path/packing.py
+++ b/trimesh/path/packing.py
@@ -5,11 +5,10 @@
 Pack rectangular regions onto larger rectangular regions.
 """
 
-from typing import Optional
-
 import numpy as np
 
 from ..constants import log, tol
+from ..typed import Integer, Number, Optional
 from ..util import allclose, bounds_tree
 
 # floating point zero
@@ -511,9 +510,9 @@ def images(
     images,
     power_resize: bool = False,
     deduplicate: bool = False,
-    iterations: Optional[int] = 50,
-    seed: Optional[int] = None,
-    spacing: Optional[float] = None,
+    iterations: Optional[Integer] = 50,
+    seed: Optional[Integer] = None,
+    spacing: Optional[Number] = None,
     mode: Optional[str] = None,
 ):
     """
diff --git a/trimesh/path/path.py b/trimesh/path/path.py
index a5589f5be..7d615e18c 100644
--- a/trimesh/path/path.py
+++ b/trimesh/path/path.py
@@ -18,7 +18,7 @@
 from ..constants import tol_path as tol
 from ..geometry import plane_transform
 from ..points import plane_fit
-from ..typed import Dict, Iterable, List, NDArray, Optional, float64
+from ..typed import ArrayLike, Dict, Iterable, List, NDArray, Optional, float64
 from ..visual import to_rgba
 from . import (
     creation,  # NOQA
@@ -73,7 +73,7 @@ class Path(parent.Geometry):
     def __init__(
         self,
         entities: Optional[Iterable[Entity]] = None,
-        vertices: Optional[NDArray[float64]] = None,
+        vertices: Optional[ArrayLike] = None,
         metadata: Optional[Dict] = None,
         process: bool = True,
         colors=None,
@@ -170,12 +170,15 @@ def colors(self, values):
             e.color = c
 
     @property
-    def vertices(self):
+    def vertices(self) -> NDArray[float64]:
         return self._vertices
 
     @vertices.setter
-    def vertices(self, values: NDArray[float64]):
-        self._vertices = caching.tracked_array(values, dtype=np.float64)
+    def vertices(self, values: Optional[ArrayLike]):
+        if values is None:
+            self._vertices = caching.tracked_array([], dtype=np.float64)
+        else:
+            self._vertices = caching.tracked_array(values, dtype=np.float64)
 
     @property
     def entities(self):
@@ -327,26 +330,7 @@ def extents(self):
         """
         return self.bounds.ptp(axis=0)
 
-    @property
-    def units(self):
-        """
-        If there are units defined in self.metadata return them.
-
-        Returns
-        -----------
-        units : str
-          Current unit system
-        """
-        if "units" in self.metadata:
-            return self.metadata["units"]
-        else:
-            return None
-
-    @units.setter
-    def units(self, units):
-        self.metadata["units"] = units
-
-    def convert_units(self, desired, guess=False):
+    def convert_units(self, desired: str, guess: bool = False):
         """
         Convert the units of the current drawing in place.
 
diff --git a/trimesh/path/polygons.py b/trimesh/path/polygons.py
index 4c051fd9e..3edf453fb 100644
--- a/trimesh/path/polygons.py
+++ b/trimesh/path/polygons.py
@@ -6,7 +6,7 @@
 from ..constants import log
 from ..constants import tol_path as tol
 from ..transformations import transform_points
-from ..typed import List, NDArray, Optional, float64
+from ..typed import Iterable, NDArray, Number, Optional, Union, float64
 from .simplify import fit_circle_check
 from .traversal import resample_path
 
@@ -164,7 +164,7 @@ def edges_to_polygons(edges, vertices):
     return complete
 
 
-def polygons_obb(polygons: List[Polygon]):
+def polygons_obb(polygons: Iterable[Polygon]):
     """
     Find the OBBs for a list of shapely.geometry.Polygons
     """
@@ -175,7 +175,7 @@ def polygons_obb(polygons: List[Polygon]):
     return np.array(transforms), np.array(rectangles)
 
 
-def polygon_obb(polygon: Polygon):
+def polygon_obb(polygon: Union[Polygon, NDArray]):
     """
     Find the oriented bounding box of a Shapely polygon.
 
@@ -368,7 +368,7 @@ def stack_boundaries(boundaries):
     return np.vstack((boundaries["shell"], np.vstack(boundaries["holes"])))
 
 
-def medial_axis(polygon: Polygon, resolution: Optional[float] = None, clip=None):
+def medial_axis(polygon: Polygon, resolution: Optional[Number] = None, clip=None):
     """
     Given a shapely polygon, find the approximate medial axis
     using a voronoi diagram of evenly spaced points on the
diff --git a/trimesh/path/segments.py b/trimesh/path/segments.py
index 3224214ab..b8a0fc2ef 100644
--- a/trimesh/path/segments.py
+++ b/trimesh/path/segments.py
@@ -11,10 +11,10 @@
 from ..constants import tol
 from ..grouping import group_rows, unique_rows
 from ..interval import union
-from ..typed import NDArray, float64
+from ..typed import ArrayLike, NDArray, float64
 
 
-def segments_to_parameters(segments: NDArray[float64]):
+def segments_to_parameters(segments: ArrayLike):
     """
     For 3D line segments defined by two points, turn
     them in to an origin defined as the closest point along
@@ -60,7 +60,7 @@ def segments_to_parameters(segments: NDArray[float64]):
 
 
 def parameters_to_segments(
-    origins: NDArray[float64], vectors: NDArray[float64], parameters: NDArray[float64]
+    origins: NDArray[float64], vectors: ArrayLike, parameters: NDArray[float64]
 ):
     """
     Convert a parametric line segment representation to
@@ -161,7 +161,7 @@ def colinear_pairs(segments, radius=0.01, angle=0.01, length=None):
     return colinear
 
 
-def clean(segments: NDArray[float64], digits: int = 10) -> NDArray[float64]:
+def clean(segments: ArrayLike, digits: int = 10) -> NDArray[float64]:
     """
     Clean up line segments by unioning the ranges of colinear segments.
 
diff --git a/trimesh/path/traversal.py b/trimesh/path/traversal.py
index ec6ed2657..20fb382e0 100644
--- a/trimesh/path/traversal.py
+++ b/trimesh/path/traversal.py
@@ -205,7 +205,7 @@ def discretize_path(entities, vertices, path, scale=1.0):
         Indexes of entities
     scale : float
         Overall scale of drawing used for
-        numeric tolerances in certain cases
+        Number tolerances in certain cases
 
     Returns
     -----------
diff --git a/trimesh/points.py b/trimesh/points.py
index 55372230d..f5cf44f7a 100644
--- a/trimesh/points.py
+++ b/trimesh/points.py
@@ -414,7 +414,6 @@ def __init__(self, vertices, colors=None, metadata=None, **kwargs):
         self._data = caching.DataStore()
         self._cache = caching.Cache(self._data.__hash__)
         self.metadata = {}
-
         if metadata is not None:
             self.metadata.update(metadata)
 
diff --git a/trimesh/repair.py b/trimesh/repair.py
index b2a6c69da..1672f4814 100644
--- a/trimesh/repair.py
+++ b/trimesh/repair.py
@@ -101,7 +101,6 @@ def fix_inversion(mesh, multibody=False):
         # this will make things worse for non-watertight meshes
         return
 
-
     if multibody:
         groups = graph.connected_components(mesh.face_adjacency)
         # escape early for single body
diff --git a/trimesh/resources/__init__.py b/trimesh/resources/__init__.py
index 039a89399..f0c66241e 100644
--- a/trimesh/resources/__init__.py
+++ b/trimesh/resources/__init__.py
@@ -2,7 +2,7 @@
 import os
 import warnings
 
-from ..typed import IO, Dict
+from ..typed import Dict, Stream
 from ..util import decode_text, wrap_as_stream
 
 # find the current absolute path to this directory
@@ -150,7 +150,7 @@ def get_bytes(name: str) -> bytes:
     return _get(name, decode=False, decode_json=False, as_stream=False)
 
 
-def get_stream(name: str) -> IO:
+def get_stream(name: str) -> Stream:
     """
     Get a resource from the `trimesh/resources` folder as a binary stream.
 
diff --git a/trimesh/resources/templates/blender_unwrap.py.template b/trimesh/resources/templates/blender_unwrap.py.template
new file mode 100644
index 000000000..d1879bef8
--- /dev/null
+++ b/trimesh/resources/templates/blender_unwrap.py.template
@@ -0,0 +1,40 @@
+# flake8: noqa
+
+import bpy
+from bl_operators.uvcalc_smart_project import main as smart_proj
+import os
+
+
+if __name__ == '__main__':
+    # clear scene of default box
+    bpy.ops.wm.read_homefile()
+    try:
+        bpy.ops.object.mode_set(mode='OBJECT')
+    except BaseException:
+        pass
+    bpy.ops.object.select_all(action='SELECT')
+    bpy.ops.object.delete(use_global=True)
+
+    # get temporary files from templated locations
+    mesh_pre = $MESH_PRE
+    mesh_post = os.path.abspath(r'$MESH_POST')
+
+    # use data.objects instead of context.scene.objects
+    bpy.ops.import_scene.obj(filepath=os.path.abspath(mesh_pre[0]))
+    bpy.ops.object.select_all(action='SELECT')
+
+    mesh = bpy.data.objects[0]
+    # Make sure mesh is the active object
+    try:
+        # earlier than blender <2.8
+        bpy.context.scene.objects.active = mesh
+    except AttributeError:
+        # blender 2.8 changed this
+        bpy.context.view_layer.objects.active = mesh
+
+    smart_proj(bpy.context, $ISLAND_MARGIN, $ANGLE_LIMIT, 0, True, True)
+
+    bpy.ops.export_scene.obj(
+        filepath=mesh_post,
+        use_mesh_modifiers=False,
+        use_uvs=True)
diff --git a/trimesh/sample.py b/trimesh/sample.py
index 9a446b74e..1b777a487 100644
--- a/trimesh/sample.py
+++ b/trimesh/sample.py
@@ -8,19 +8,17 @@
 import numpy as np
 
 from . import transformations, util
+from .typed import ArrayLike, Integer, NDArray, Number, Optional, float64
 from .visual import uv_to_interpolated_color
 
-if hasattr(np.random, "default_rng"):
-    # newer versions of Numpy
-    default_rng = np.random.default_rng
-else:
-    # Python 2 Numpy
-    class default_rng(np.random.RandomState):
-        def random(self, *args, **kwargs):
-            return self.random_sample(*args, **kwargs)
 
-
-def sample_surface(mesh, count, face_weight=None, sample_color=False, seed=None):
+def sample_surface(
+    mesh,
+    count: Integer,
+    face_weight: Optional[ArrayLike] = None,
+    sample_color=False,
+    seed=None,
+):
     """
     Sample the surface of a mesh, returning the specified
     number of points
@@ -64,7 +62,10 @@ def sample_surface(mesh, count, face_weight=None, sample_color=False, seed=None)
     weight_cum = np.cumsum(face_weight)
 
     # seed the random number generator as requested
-    random = default_rng(seed).random
+    if seed is None:
+        random = np.random.random
+    else:
+        random = np.random.default_rng(seed).random
 
     # last value of cumulative sum is total summed weight/area
     face_pick = random(count) * weight_cum[-1]
@@ -120,7 +121,7 @@ def sample_surface(mesh, count, face_weight=None, sample_color=False, seed=None)
     return samples, face_index
 
 
-def volume_mesh(mesh, count):
+def volume_mesh(mesh, count: Integer) -> NDArray[float64]:
     """
     Use rejection sampling to produce points randomly
     distributed in the volume of a mesh.
@@ -144,7 +145,9 @@ def volume_mesh(mesh, count):
     return samples
 
 
-def volume_rectangular(extents, count, transform=None):
+def volume_rectangular(
+    extents, count: Integer, transform: Optional[ArrayLike] = None
+) -> NDArray[float64]:
     """
     Return random samples inside a rectangular volume,
     useful for sampling inside oriented bounding boxes.
@@ -170,7 +173,7 @@ def volume_rectangular(extents, count, transform=None):
     return samples
 
 
-def sample_surface_even(mesh, count, radius=None, seed=None):
+def sample_surface_even(mesh, count: Integer, radius: Optional[Number] = None, seed=None):
     """
     Sample the surface of a mesh, returning samples which are
     VERY approximately evenly spaced. This is accomplished by
@@ -220,7 +223,7 @@ def sample_surface_even(mesh, count, radius=None, seed=None):
     return points, index[mask]
 
 
-def sample_surface_sphere(count):
+def sample_surface_sphere(count: int) -> NDArray[float64]:
     """
     Correctly pick random points on the surface of a unit sphere
 
diff --git a/trimesh/scene/scene.py b/trimesh/scene/scene.py
index 987b05805..6162440d6 100644
--- a/trimesh/scene/scene.py
+++ b/trimesh/scene/scene.py
@@ -4,6 +4,7 @@
 import numpy as np
 
 from .. import caching, convex, grouping, inertia, transformations, units, util
+from ..constants import log
 from ..exchange import export
 from ..parent import Geometry, Geometry3D
 from ..typed import (
@@ -492,7 +493,7 @@ def moment_inertia_frame(self, transform):
         return inertia.scene_inertia(scene=self, transform=transform)
 
     @caching.cache_decorator
-    def area(self) -> float64:
+    def area(self) -> float:
         """
         What is the summed area of every geometry which
         has area.
@@ -538,9 +539,8 @@ def triangles(self) -> NDArray[float64]:
         triangles : (n, 3, 3) float
           Triangles in space
         """
-        triangles = collections.deque()
-        triangles_node = collections.deque()
-
+        triangles = []
+        triangles_node = []
         for node_name in self.graph.nodes_geometry:
             # which geometry does this node refer to
             transform, geometry_name = self.graph[node_name]
@@ -559,8 +559,7 @@ def triangles(self) -> NDArray[float64]:
             triangles_node.append(np.tile(node_name, len(geometry.triangles)))
         # save the resulting nodes to the cache
         self._cache["triangles_node"] = np.hstack(triangles_node)
-        triangles = np.vstack(triangles).reshape((-1, 3, 3))
-        return triangles
+        return np.vstack(triangles).reshape((-1, 3, 3))
 
     @caching.cache_decorator
     def triangles_node(self):
@@ -716,7 +715,7 @@ def camera_transform(self):
         return self.graph[self.camera.name][0]
 
     @camera_transform.setter
-    def camera_transform(self, matrix: NDArray[float64]):
+    def camera_transform(self, matrix: ArrayLike):
         """
         Set the camera transform in the base frame
 
@@ -995,6 +994,8 @@ def units(self) -> Optional[str]:
         existing = {i.units for i in self.geometry.values()}
         if len(existing) == 1:
             return existing.pop()
+        elif len(existing) > 1:
+            log.warning(f"Mixed units `{existing}` returning None")
         return None
 
     @units.setter
@@ -1008,6 +1009,7 @@ def units(self, value: str):
         value : str
           Value to set every geometry unit value to
         """
+        value = value.strip().lower()
         for m in self.geometry.values():
             m.units = value
 
diff --git a/trimesh/scene/transforms.py b/trimesh/scene/transforms.py
index 1b3aef51e..7b006c3a9 100644
--- a/trimesh/scene/transforms.py
+++ b/trimesh/scene/transforms.py
@@ -1,12 +1,12 @@
 import collections
 from copy import deepcopy
-from typing import Sequence, Union
 
 import numpy as np
 
 from .. import caching, util
 from ..caching import hash_fast
 from ..transformations import fix_rigid, quaternion_matrix, rotation_matrix
+from ..typed import Sequence, Union
 
 # we compare to identity a lot
 _identity = np.eye(4)
diff --git a/trimesh/typed.py b/trimesh/typed.py
index 63d91abd3..e8e6bc180 100644
--- a/trimesh/typed.py
+++ b/trimesh/typed.py
@@ -1,29 +1,44 @@
+from io import BytesIO, StringIO
 from pathlib import Path
+from sys import version_info
 from typing import (
     IO,
     Any,
     BinaryIO,
-    Dict,
-    Iterable,
-    List,
     Optional,
-    Sequence,
-    Tuple,
+    TextIO,
     Union,
 )
 
-# our default integer and floating point types
-from numpy import float64, int64
+from numpy import float64, floating, int64, integer, unsignedinteger
 
-try:
-    from numpy.typing import ArrayLike, NDArray
-except BaseException:
-    NDArray = Sequence
-    ArrayLike = Sequence
+# requires numpy>=1.20
+from numpy.typing import ArrayLike, NDArray
+
+if version_info >= (3, 9):
+    # use PEP585 hints on newer python
+    List = list
+    Tuple = tuple
+    Dict = dict
+    from collections.abc import Iterable, Sequence
+else:
+    from typing import Dict, Iterable, List, Sequence, Tuple
 
 # most loader routes take `file_obj` which can either be
-# a file-like object or a file path
-Loadable = Union[str, Path, IO]
+# a file-like object or a file path, or sometimes a dict
+
+Stream = Union[IO, BytesIO, StringIO, BinaryIO, TextIO]
+Loadable = Union[str, Path, Stream, Dict, None]
+
+# numpy integers do not inherit from python integers, i.e.
+# if you type a function argument as an `int` and then pass
+# a value from a numpy array like `np.ones(10, dtype=np.int64)[0]`
+# you may have a type error.
+# these wrappers union numpy integers and python integers
+Integer = Union[int, integer, unsignedinteger]
+
+# Many arguments take "any valid number."
+Number = Union[float, floating, Integer]
 
 __all__ = [
     "NDArray",
@@ -40,4 +55,7 @@
     "Tuple",
     "float64",
     "int64",
+    "Number",
+    "Integer",
+    "Stream",
 ]
diff --git a/trimesh/util.py b/trimesh/util.py
index aeeb5ee3c..1f3a5331a 100644
--- a/trimesh/util.py
+++ b/trimesh/util.py
@@ -1444,16 +1444,24 @@ def concatenate(a, b=None):
             flat.extend(b)
         else:
             flat.append(b)
+    dump = []
+    for i in flat:
+        if is_instance_named(i, "Scene"):
+            dump.extend(i.dump())
+        else:
+            dump.append(i)
 
-    if len(flat) == 1:
+    if len(dump) == 1:
         # if there is only one mesh just return the first
-        return flat[0].copy()
-    elif len(flat) == 0:
-        # if there are no meshes return an empty list
-        return []
+        return dump[0].copy()
+    elif len(dump) == 0:
+        # if there are no meshes return an empty mesh
+        from .base import Trimesh
+
+        return Trimesh()
 
-    is_mesh = [f for f in flat if is_instance_named(f, "Trimesh")]
-    is_path = [f for f in flat if is_instance_named(f, "Path")]
+    is_mesh = [f for f in dump if is_instance_named(f, "Trimesh")]
+    is_path = [f for f in dump if is_instance_named(f, "Path")]
 
     if len(is_path) > len(is_mesh):
         from .path.util import concatenate as concatenate_path
@@ -2107,7 +2115,7 @@ def write_encoded(file_obj, stuff, encoding="utf-8"):
 
 def unique_id(length=12):
     """
-    Generate a random alphanumeric unique identifier
+    Generate a random alphaNumber unique identifier
     using UUID logic.
 
     Parameters
@@ -2118,7 +2126,7 @@ def unique_id(length=12):
     Returns
     ------------
     unique : str
-      Unique alphanumeric identifier
+      Unique alphaNumber identifier
     """
     return uuid.UUID(int=random.getrandbits(128), version=4).hex[:length]
 
diff --git a/trimesh/visual/base.py b/trimesh/visual/base.py
index 5a04d702a..5810a6734 100644
--- a/trimesh/visual/base.py
+++ b/trimesh/visual/base.py
@@ -15,7 +15,8 @@ class Visuals(ABC):
     Parent of Visual classes.
     """
 
-    @abc.abstractproperty
+    @property
+    @abc.abstractmethod
     def kind(self):
         pass
 
diff --git a/trimesh/visual/gloss.py b/trimesh/visual/gloss.py
index 5d3bdf30e..08ab324dc 100644
--- a/trimesh/visual/gloss.py
+++ b/trimesh/visual/gloss.py
@@ -2,7 +2,7 @@
 
 from ..constants import log
 from ..exceptions import ExceptionWrapper
-from ..typed import NDArray, Optional, float64
+from ..typed import ArrayLike, Number, Optional
 
 try:
     from PIL.Image import Image, fromarray
@@ -12,11 +12,11 @@
 
 
 def specular_to_pbr(
-    specularFactor: Optional[NDArray[float64]] = None,
-    glossinessFactor: Optional[float] = None,
+    specularFactor: Optional[ArrayLike] = None,
+    glossinessFactor: Optional[Number] = None,
     specularGlossinessTexture: Optional["Image"] = None,
     diffuseTexture: Optional["Image"] = None,
-    diffuseFactor: float = None,
+    diffuseFactor: Optional[ArrayLike] = None,
     **kwargs,
 ) -> dict:
     """
diff --git a/trimesh/visual/material.py b/trimesh/visual/material.py
index 913fc9a15..09c137509 100644
--- a/trimesh/visual/material.py
+++ b/trimesh/visual/material.py
@@ -32,7 +32,8 @@ def __init__(self, *args, **kwargs):
     def __hash__(self):
         raise NotImplementedError("must be subclassed!")
 
-    @abc.abstractproperty
+    @property
+    @abc.abstractmethod
     def main_color(self):
         """
         The "average" color of this material.
@@ -726,7 +727,7 @@ def empty_material(color: Optional[NDArray[np.uint8]] = None) -> SimpleMaterial:
     return SimpleMaterial(image=color_image(color=color))
 
 
-def color_image(color: Optional[NDArray[np.uint8]] = None) -> "Image":
+def color_image(color: Optional[NDArray[np.uint8]] = None):
     """
     Generate an image with one color.
 
diff --git a/trimesh/voxel/encoding.py b/trimesh/voxel/encoding.py
index 83486f575..02e669006 100644
--- a/trimesh/voxel/encoding.py
+++ b/trimesh/voxel/encoding.py
@@ -39,31 +39,38 @@ def __init__(self, data):
         self._data = data
         self._cache = caching.Cache(id_function=self._data.__hash__)
 
-    @abc.abstractproperty
+    @property
+    @abc.abstractmethod
     def dtype(self):
         pass
 
-    @abc.abstractproperty
+    @property
+    @abc.abstractmethod
     def shape(self):
         pass
 
-    @abc.abstractproperty
+    @property
+    @abc.abstractmethod
     def sum(self):
         pass
 
-    @abc.abstractproperty
+    @property
+    @abc.abstractmethod
     def size(self):
         pass
 
-    @abc.abstractproperty
+    @property
+    @abc.abstractmethod
     def sparse_indices(self):
         pass
 
-    @abc.abstractproperty
+    @property
+    @abc.abstractmethod
     def sparse_values(self):
         pass
 
-    @abc.abstractproperty
+    @property
+    @abc.abstractmethod
     def dense(self):
         pass
 
diff --git a/trimesh/voxel/transforms.py b/trimesh/voxel/transforms.py
index 0cf7969f5..f611cc7f3 100644
--- a/trimesh/voxel/transforms.py
+++ b/trimesh/voxel/transforms.py
@@ -1,9 +1,8 @@
-from typing import Optional
-
 import numpy as np
 
 from .. import caching, util
 from .. import transformations as tr
+from ..typed import Optional
 
 
 class Transform:


Test patch:
diff --git a/tests/test_convex.py b/tests/test_convex.py
index 3433ec0a7..173a6e622 100644
--- a/tests/test_convex.py
+++ b/tests/test_convex.py
@@ -20,7 +20,7 @@ def test_convex(self):
             (
                 False,
                 (
-                    g.trimesh.creation.box(extents=(1, 1, 1))
+                    g.trimesh.creation.box(extents=[1, 1, 1])
                     + g.trimesh.creation.box(bounds=[[10, 10, 10], [12, 12, 12]])
                 ),
             ),
diff --git a/tests/test_creation.py b/tests/test_creation.py
index 581996c16..bc06f9cb8 100644
--- a/tests/test_creation.py
+++ b/tests/test_creation.py
@@ -100,7 +100,7 @@ def test_spheres(self):
             assert g.np.allclose(radii, 1.0)
 
         # test additional arguments
-        red_sphere = g.trimesh.creation.icosphere(face_colors=(1.0, 0, 0))
+        red_sphere = g.trimesh.creation.icosphere(face_colors=[1.0, 0, 0])
         expected = g.np.full((len(red_sphere.faces), 4), (255, 0, 0, 255))
         g.np.testing.assert_allclose(red_sphere.visual.face_colors, expected)
 
diff --git a/tests/test_scene.py b/tests/test_scene.py
index a86a64482..03473e6d7 100644
--- a/tests/test_scene.py
+++ b/tests/test_scene.py
@@ -179,6 +179,7 @@ def test_mixed_units(self):
 
         # mixed units should be None
         s = g.trimesh.Scene([a, b])
+        assert len(s.geometry) == 2
         assert s.units is None
 
         # now all units should be meters and scene should report that
diff --git a/tests/test_unwrap.py b/tests/test_unwrap.py
index 5bc1b16ed..128880a78 100644
--- a/tests/test_unwrap.py
+++ b/tests/test_unwrap.py
@@ -27,6 +27,13 @@ def test_image(self):
         # make sure image was attached correctly
         assert u.visual.material.image.size == image.size
 
+    def test_blender_unwrap(self):
+        if not g.trimesh.interfaces.blender.exists:
+            return
+        m = g.get_mesh("rabbit.obj")
+        # TODO : verify this returns geometry!
+        _todo = g.trimesh.interfaces.blender.unwrap(m)
+
 
 if __name__ == "__main__":
     g.trimesh.util.attach_to_log()