Repository: mikedh/trimesh

Issue:
Error loading .glb
I'm using trimesh==4.0.10. When I load .glb here:
[tmpnnnzejqq.glb.zip](https://github.com/mikedh/trimesh/files/14034927/tmpnnnzejqq.glb.zip)


```python
glb_obj = trimesh.load(glb_file)
```

It raises:
> 4606659ff31142e3b8080f995514452f, all the input arrays must have same number of dimensions, but the array at index 0 has 3 dimension(s) and the array at index 1 has 1 dimension(s)



Discussion:

Patch:
diff --git a/examples/stl_import_with_mixed_case_names.py b/examples/stl_import_with_mixed_case_names.py
new file mode 100644
index 00000000..ce8a6558
--- /dev/null
+++ b/examples/stl_import_with_mixed_case_names.py
@@ -0,0 +1,7 @@
+import trimesh
+
+# Load stl file with mixed case name
+mesh = trimesh.load_mesh("models/two_objects_mixed_case_names.stl")
+# Print some geometry info to test
+for k, v in mesh.geometry.items():
+    print("Sub-mesh Name:", k, "\nSub-mesh Min X value", min(v.vertices[:, 0]))
diff --git a/models/two_objects_mixed_case_names.stl b/models/two_objects_mixed_case_names.stl
new file mode 100644
index 00000000..ebbe27b3
--- /dev/null
+++ b/models/two_objects_mixed_case_names.stl
@@ -0,0 +1,172 @@
+solid CubeExportedFromCAD
+   facet normal -1.000000e+00 -4.336808e-16 -0.000000e+00
+      outer loop
+         vertex 4.336809e-16 0.000000e+00 1.000000e+00
+         vertex 0.000000e+00 1.000000e+00 1.000000e+00
+         vertex 4.336809e-16 0.000000e+00 0.000000e+00
+      endloop
+   endfacet
+   facet normal -1.000000e+00 -4.336808e-16 0.000000e+00
+      outer loop
+         vertex 4.336809e-16 0.000000e+00 0.000000e+00
+         vertex 0.000000e+00 1.000000e+00 1.000000e+00
+         vertex 0.000000e+00 1.000000e+00 0.000000e+00
+      endloop
+   endfacet
+   facet normal -0.000000e+00 -1.000000e+00 -0.000000e+00
+      outer loop
+         vertex 1.000000e+00 0.000000e+00 1.000000e+00
+         vertex 4.336809e-16 0.000000e+00 1.000000e+00
+         vertex 1.000000e+00 0.000000e+00 0.000000e+00
+      endloop
+   endfacet
+   facet normal 0.000000e+00 -1.000000e+00 0.000000e+00
+      outer loop
+         vertex 1.000000e+00 0.000000e+00 0.000000e+00
+         vertex 4.336809e-16 0.000000e+00 1.000000e+00
+         vertex 4.336809e-16 0.000000e+00 0.000000e+00
+      endloop
+   endfacet
+   facet normal 1.000000e+00 0.000000e+00 0.000000e+00
+      outer loop
+         vertex 1.000000e+00 1.000000e+00 1.000000e+00
+         vertex 1.000000e+00 0.000000e+00 1.000000e+00
+         vertex 1.000000e+00 1.000000e+00 0.000000e+00
+      endloop
+   endfacet
+   facet normal 1.000000e+00 0.000000e+00 0.000000e+00
+      outer loop
+         vertex 1.000000e+00 1.000000e+00 0.000000e+00
+         vertex 1.000000e+00 0.000000e+00 1.000000e+00
+         vertex 1.000000e+00 0.000000e+00 0.000000e+00
+      endloop
+   endfacet
+   facet normal -0.000000e+00 1.000000e+00 0.000000e+00
+      outer loop
+         vertex 0.000000e+00 1.000000e+00 1.000000e+00
+         vertex 1.000000e+00 1.000000e+00 1.000000e+00
+         vertex 0.000000e+00 1.000000e+00 0.000000e+00
+      endloop
+   endfacet
+   facet normal 0.000000e+00 1.000000e+00 0.000000e+00
+      outer loop
+         vertex 0.000000e+00 1.000000e+00 0.000000e+00
+         vertex 1.000000e+00 1.000000e+00 1.000000e+00
+         vertex 1.000000e+00 1.000000e+00 0.000000e+00
+      endloop
+   endfacet
+   facet normal 0.000000e+00 -0.000000e+00 1.000000e+00
+      outer loop
+         vertex 1.000000e+00 0.000000e+00 1.000000e+00
+         vertex 1.000000e+00 1.000000e+00 1.000000e+00
+         vertex 4.336809e-16 0.000000e+00 1.000000e+00
+      endloop
+   endfacet
+   facet normal 0.000000e+00 -0.000000e+00 1.000000e+00
+      outer loop
+         vertex 4.336809e-16 0.000000e+00 1.000000e+00
+         vertex 1.000000e+00 1.000000e+00 1.000000e+00
+         vertex 0.000000e+00 1.000000e+00 1.000000e+00
+      endloop
+   endfacet
+   facet normal -0.000000e+00 -0.000000e+00 -1.000000e+00
+      outer loop
+         vertex 1.000000e+00 1.000000e+00 0.000000e+00
+         vertex 1.000000e+00 0.000000e+00 0.000000e+00
+         vertex 0.000000e+00 1.000000e+00 0.000000e+00
+      endloop
+   endfacet
+   facet normal 0.000000e+00 0.000000e+00 -1.000000e+00
+      outer loop
+         vertex 0.000000e+00 1.000000e+00 0.000000e+00
+         vertex 1.000000e+00 0.000000e+00 0.000000e+00
+         vertex 4.336809e-16 0.000000e+00 0.000000e+00
+      endloop
+   endfacet
+endsolid
+solid TranslatedCubeExportedFromCAD
+   facet normal -1.000000e+00 -4.336808e-16 -0.000000e+00
+      outer loop
+         vertex 5.0e+00 0.000000e+00 1.000000e+00
+         vertex 5.0e+00 1.000000e+00 1.000000e+00
+         vertex 5.0e+00 0.000000e+00 0.000000e+00
+      endloop
+   endfacet
+   facet normal -1.000000e+00 -4.336808e-16 0.000000e+00
+      outer loop
+         vertex 5.0e+00 0.000000e+00 0.000000e+00
+         vertex 5.0e+00 1.000000e+00 1.000000e+00
+         vertex 5.0e+00 1.000000e+00 0.000000e+00
+      endloop
+   endfacet
+   facet normal -0.000000e+00 -1.000000e+00 -0.000000e+00
+      outer loop
+         vertex 6.000000e+00 0.000000e+00 1.000000e+00
+         vertex 5.0e+00 0.000000e+00 1.000000e+00
+         vertex 6.000000e+00 0.000000e+00 0.000000e+00
+      endloop
+   endfacet
+   facet normal 0.000000e+00 -1.000000e+00 0.000000e+00
+      outer loop
+         vertex 6.000000e+00 0.000000e+00 0.000000e+00
+         vertex 5.0e+00 0.000000e+00 1.000000e+00
+         vertex 5.0e+00 0.000000e+00 0.000000e+00
+      endloop
+   endfacet
+   facet normal 1.000000e+00 0.000000e+00 0.000000e+00
+      outer loop
+         vertex 6.000000e+00 1.000000e+00 1.000000e+00
+         vertex 6.000000e+00 0.000000e+00 1.000000e+00
+         vertex 6.000000e+00 1.000000e+00 0.000000e+00
+      endloop
+   endfacet
+   facet normal 1.000000e+00 0.000000e+00 0.000000e+00
+      outer loop
+         vertex 6.000000e+00 1.000000e+00 0.000000e+00
+         vertex 6.000000e+00 0.000000e+00 1.000000e+00
+         vertex 6.000000e+00 0.000000e+00 0.000000e+00
+      endloop
+   endfacet
+   facet normal -0.000000e+00 1.000000e+00 0.000000e+00
+      outer loop
+         vertex 5.000000e+00 1.000000e+00 1.000000e+00
+         vertex 6.000000e+00 1.000000e+00 1.000000e+00
+         vertex 5.000000e+00 1.000000e+00 0.000000e+00
+      endloop
+   endfacet
+   facet normal 0.000000e+00 1.000000e+00 0.000000e+00
+      outer loop
+         vertex 5.000000e+00 1.000000e+00 0.000000e+00
+         vertex 6.000000e+00 1.000000e+00 1.000000e+00
+         vertex 6.000000e+00 1.000000e+00 0.000000e+00
+      endloop
+   endfacet
+   facet normal 0.000000e+00 -0.000000e+00 1.000000e+00
+      outer loop
+         vertex 6.000000e+00 0.000000e+00 1.000000e+00
+         vertex 6.000000e+00 1.000000e+00 1.000000e+00
+         vertex 5.0e+00 0.000000e+00 1.000000e+00
+      endloop
+   endfacet
+   facet normal 0.000000e+00 -0.000000e+00 1.000000e+00
+      outer loop
+         vertex 5.0e+00 0.000000e+00 1.000000e+00
+         vertex 6.000000e+00 1.000000e+00 1.000000e+00
+         vertex 5.000000e+00 1.000000e+00 1.000000e+00
+      endloop
+   endfacet
+   facet normal -0.000000e+00 -0.000000e+00 -1.000000e+00
+      outer loop
+         vertex 6.000000e+00 1.000000e+00 0.000000e+00
+         vertex 6.000000e+00 0.000000e+00 0.000000e+00
+         vertex 5.000000e+00 1.000000e+00 0.000000e+00
+      endloop
+   endfacet
+   facet normal 0.000000e+00 0.000000e+00 -1.000000e+00
+      outer loop
+         vertex 5.000000e+00 1.000000e+00 0.000000e+00
+         vertex 6.000000e+00 0.000000e+00 0.000000e+00
+         vertex 5.0e+00 0.000000e+00 0.000000e+00
+      endloop
+   endfacet
+endsolid
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index a26764d0..09ec756e 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.0.10"
+version = "4.1.0"
 authors = [{name = "Michael Dawson-Haggerty", email = "mikedh@kerfed.com"}]
 license = {file = "LICENSE.md"}
 description = "Import, export, process, analyze and view triangular meshes."
diff --git a/trimesh/base.py b/trimesh/base.py
index 2f374005..8c8b2d5a 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -528,7 +528,7 @@ class Trimesh(Geometry3D):
         return vertex_faces
 
     @caching.cache_decorator
-    def bounds(self) -> NDArray[float64]:
+    def bounds(self) -> Optional[NDArray[float64]]:
         """
         The axis aligned bounds of the faces of the mesh.
 
@@ -547,7 +547,7 @@ class Trimesh(Geometry3D):
         return np.array([in_mesh.min(axis=0), in_mesh.max(axis=0)])
 
     @caching.cache_decorator
-    def extents(self) -> NDArray[float64]:
+    def extents(self) -> Optional[NDArray[float64]]:
         """
         The length, width, and height of the axis aligned
         bounding box of the mesh.
diff --git a/trimesh/exchange/gltf.py b/trimesh/exchange/gltf.py
index f2670e71..72de7203 100644
--- a/trimesh/exchange/gltf.py
+++ b/trimesh/exchange/gltf.py
@@ -203,10 +203,6 @@ def export_glb(
         extension_webp=extension_webp,
     )
 
-    # allow custom postprocessing
-    if tree_postprocessor is not None:
-        tree_postprocessor(tree)
-
     # A bufferView is a slice of a file
     views = _build_views(buffer_items)
 
@@ -218,6 +214,10 @@ def export_glb(
         tree["buffers"] = [{"byteLength": len(buffer_data)}]
         tree["bufferViews"] = views
 
+    # allow custom postprocessing
+    if tree_postprocessor is not None:
+        tree_postprocessor(tree)
+
     # export the tree to JSON for the header
     content = util.jsonify(tree, separators=(",", ":"))
     # add spaces to content, so the start of the data
diff --git a/trimesh/exchange/stl.py b/trimesh/exchange/stl.py
index a979c345..57e763cb 100644
--- a/trimesh/exchange/stl.py
+++ b/trimesh/exchange/stl.py
@@ -149,19 +149,44 @@ def load_stl_ascii(file_obj):
     ----------
     loaded : dict
       kwargs for a Trimesh constructor with keys:
-      vertices:     (n,3) float, vertices
-      faces:        (m,3) int, indexes of vertices
-      face_normals: (m,3) float, normal vector of each face
+      vertices:     (n, 3) float, vertices
+      faces:        (m, 3) int, indexes of vertices
+      face_normals: (m, 3) float, normal vector of each face
     """
 
     # read all text into one string
-    raw = util.decode_text(file_obj.read()).strip().lower()
+    raw_mixed = util.decode_text(file_obj.read()).strip()
+    # convert to lower case for solids and name capture
+    raw_lower = raw_mixed.lower()
 
-    # split into solid body
+    # collect the keyword arguments for the Trimesh constructor
     kwargs = {}
-    solids = raw.split("endsolid")
-    for solid in solids:
-        # get just the vertices
+
+    # keep track of our position in the file
+    position = 0
+
+    # use a for loop to avoid any possibility of infinite looping
+    for _ in range(len(raw_mixed)):
+        # find the start of the solid chunk
+        solid_start = raw_lower.find("solid", position)
+        # find the end of the solid chunk
+        solid_end = raw_lower.find("endsolid", position)
+
+        # on the next loop we don't have to check the text we've consumed
+        position = solid_end + len("endsolid")
+
+        # delimiter wasn't found for a chunk so exit
+        if solid_end < 0 or solid_start < 0:
+            break
+
+        # end delimiter order is wrong so this file is very malformed
+        if solid_start > solid_end:
+            raise ValueError("`endsolid` precedes `solid`!")
+
+        # get the chunk of text with this particular solid
+        solid = raw_lower[solid_start:solid_end]
+
+        # extract the vertices
         vertex_text = solid.split("vertex")
         vertices = np.fromstring(
             " ".join(line[: line.find("\n")] for line in vertex_text[1:]),
@@ -192,21 +217,16 @@ def load_stl_ascii(file_obj):
             util.log.warning("failed to extract face_normals", exc_info=True)
 
         try:
-            # try to extract the name from the header
-            text = vertex_text[0]
-            # find the keyword for the header format:
-            #    `solid {name}`
-            index = text.find("solid")
-            if index < 0:
-                raise ValueError("missing `solid` keyword")
-            # clip to the first newline after the `solid`
-            name = text[index + 6 :].strip().split("\n", 1)[0].strip()
+            # Previously checked to make sure there was matching 'solid' for 'endsolid'
+            # the name is right after the `solid` keyword if it exists
+            name = raw_mixed[solid_start : solid_start + solid.find("\n")][6:].strip()
         except BaseException:
             # will be filled in by unique_name
             name = None
 
-        # make sure geometry has a unique name
+        # make sure geometry has a unique name for the scene
         name = util.unique_name(name, kwargs)
+        # save the constructor arguments
         kwargs[name] = {
             "vertices": vertices.reshape((-1, 3)),
             "face_normals": face_normals,
diff --git a/trimesh/scene/scene.py b/trimesh/scene/scene.py
index 8961d737..c9abe1f1 100644
--- a/trimesh/scene/scene.py
+++ b/trimesh/scene/scene.py
@@ -5,8 +5,8 @@ import numpy as np
 
 from .. import caching, convex, grouping, inertia, transformations, units, util
 from ..exchange import export
-from ..parent import Geometry3D
-from ..typed import Optional
+from ..parent import Geometry, Geometry3D
+from ..typed import Dict, List, NDArray, Optional, Tuple, Union, float64, int64
 from ..util import unique_name
 from . import cameras, lighting
 from .transforms import SceneGraph
@@ -22,13 +22,13 @@ class Scene(Geometry3D):
 
     def __init__(
         self,
-        geometry=None,
-        base_frame="world",
-        metadata=None,
-        graph=None,
-        camera=None,
-        lights=None,
-        camera_transform=None,
+        geometry: Union[Geometry, None, List[Geometry]] = None,
+        base_frame: str = "world",
+        metadata: Optional[Dict] = None,
+        graph: Optional[SceneGraph] = None,
+        camera: Optional[cameras.Camera] = None,
+        lights: Optional[lighting.Light] = None,
+        camera_transform: Optional[NDArray] = None,
     ):
         """
         Create a new Scene object.
@@ -37,18 +37,18 @@ class Scene(Geometry3D):
         -------------
         geometry : Trimesh, Path2D, Path3D PointCloud or list
           Geometry to initially add to the scene
-        base_frame : str or hashable
+        base_frame
           Name of base frame
-        metadata : dict
+        metadata
           Any metadata about the scene
-        graph : TransformForest or None
+        graph
           A passed transform graph to use
         camera : Camera or None
           A passed camera to use
         lights : [trimesh.scene.lighting.Light] or None
           A passed lights to use
-        camera_transform : (4, 4) float or None
-          Camera transform in the base frame
+        camera_transform
+          Homogenous (4, 4) camera transform in the base frame
         """
         # mesh name : Trimesh object
         self.geometry = collections.OrderedDict()
@@ -95,12 +95,12 @@ class Scene(Geometry3D):
 
     def add_geometry(
         self,
-        geometry,
-        node_name=None,
-        geom_name=None,
-        parent_node_name=None,
-        transform=None,
-        metadata=None,
+        geometry: Union[Geometry, None, List[Geometry]] = None,
+        node_name: Optional[str] = None,
+        geom_name: Optional[str] = None,
+        parent_node_name: Optional[str] = None,
+        transform: Optional[NDArray] = None,
+        metadata: Optional[Dict] = None,
     ):
         """
         Add a geometry to the scene.
@@ -211,7 +211,7 @@ class Scene(Geometry3D):
 
         return node_name
 
-    def delete_geometry(self, names):
+    def delete_geometry(self, names: Union[set, str]) -> None:
         """
         Delete one more multiple geometries from the scene and also
         remove any node in the transform graph which references it.
@@ -231,7 +231,7 @@ class Scene(Geometry3D):
         # remove the geometries from our geometry store
         [self.geometry.pop(name, None) for name in names]
 
-    def strip_visuals(self):
+    def strip_visuals(self) -> None:
         """
         Strip visuals from every Trimesh geometry
         and set them to an empty `ColorVisuals`.
@@ -242,14 +242,14 @@ class Scene(Geometry3D):
             if util.is_instance_named(geometry, "Trimesh"):
                 geometry.visual = ColorVisuals(mesh=geometry)
 
-    def __hash__(self):
+    def __hash__(self) -> str:
         """
         Return information about scene which is hashable.
 
         Returns
         ---------
-        hashable : str
-          Data which can be hashed.
+        hashed
+          String hashing scene.
         """
         # avoid accessing attribute in tight loop
         geometry = self.geometry
@@ -261,20 +261,20 @@ class Scene(Geometry3D):
         return caching.hash_fast("".join(hashable).encode("utf-8"))
 
     @property
-    def is_empty(self):
+    def is_empty(self) -> bool:
         """
         Does the scene have anything in it.
 
         Returns
         ----------
-        is_empty: bool, True if nothing is in the scene
+        is_empty
+          True if nothing is in the scene
         """
 
-        is_empty = len(self.geometry) == 0
-        return is_empty
+        return len(self.geometry) == 0
 
     @property
-    def is_valid(self):
+    def is_valid(self) -> bool:
         """
         Is every geometry connected to the root node.
 
@@ -293,19 +293,17 @@ class Scene(Geometry3D):
             return False
 
         # every geometry is referenced
-        ok = referenced == set(self.geometry.keys())
-
-        return ok
+        return referenced == set(self.geometry.keys())
 
     @caching.cache_decorator
-    def bounds_corners(self):
+    def bounds_corners(self) -> Dict[str, NDArray[float64]]:
         """
         Get the post-transform AABB for each node
         which has geometry defined.
 
         Returns
         -----------
-        corners : dict
+        corners
           Bounds for each node with vertices:
            {node_name : (2, 3) float}
         """
@@ -344,7 +342,7 @@ class Scene(Geometry3D):
         return corners
 
     @caching.cache_decorator
-    def bounds(self):
+    def bounds(self) -> Optional[NDArray[float64]]:
         """
         Return the overall bounding box of the scene.
 
@@ -362,19 +360,23 @@ class Scene(Geometry3D):
         return np.array([corners.min(axis=0), corners.max(axis=0)], dtype=np.float64)
 
     @caching.cache_decorator
-    def extents(self):
+    def extents(self) -> Optional[NDArray[float64]]:
         """
-        Return the axis aligned box size of the current scene.
+        Return the axis aligned box size of the current scene
+        or None if the scene is empty.
 
         Returns
         ----------
-        extents : (3,) float
-          Bounding box sides length
+        extents
+          Bounding box sides length or None for empty scene.
         """
-        return np.diff(self.bounds, axis=0).reshape(-1)
+        bounds = self.bounds
+        if bounds is None:
+            return None
+        return np.diff(bounds, axis=0).reshape(-1)
 
     @caching.cache_decorator
-    def scale(self):
+    def scale(self) -> float:
         """
         The approximate scale of the mesh
 
@@ -383,11 +385,13 @@ class Scene(Geometry3D):
         scale : float
           The mean of the bounding box edge lengths
         """
-        scale = (self.extents**2).sum() ** 0.5
-        return scale
+        extents = self.extents
+        if extents is None:
+            return 1.0
+        return float((extents**2).sum() ** 0.5)
 
     @caching.cache_decorator
-    def centroid(self):
+    def centroid(self) -> Optional[NDArray[float64]]:
         """
         Return the center of the bounding box for the scene.
 
@@ -396,11 +400,14 @@ class Scene(Geometry3D):
         centroid : (3) float
           Point for center of bounding box
         """
+        bounds = self.bounds
+        if bounds is None:
+            return None
         centroid = np.mean(self.bounds, axis=0)
         return centroid
 
     @caching.cache_decorator
-    def center_mass(self):
+    def center_mass(self) -> NDArray:
         """
         Find the center of mass for every instance in the scene.
 
@@ -467,7 +474,7 @@ class Scene(Geometry3D):
         return inertia.scene_inertia(scene=self, transform=transform)
 
     @caching.cache_decorator
-    def area(self):
+    def area(self) -> float64:
         """
         What is the summed area of every geometry which
         has area.
@@ -485,7 +492,7 @@ class Scene(Geometry3D):
         )
 
     @caching.cache_decorator
-    def volume(self):
+    def volume(self) -> float64:
         """
         What is the summed volume of every geometry which
         has volume
@@ -503,7 +510,7 @@ class Scene(Geometry3D):
         )
 
     @caching.cache_decorator
-    def triangles(self):
+    def triangles(self) -> NDArray[float64]:
         """
         Return a correctly transformed polygon soup of the
         current scene.
@@ -551,20 +558,20 @@ class Scene(Geometry3D):
         return self._cache["triangles_node"]
 
     @caching.cache_decorator
-    def geometry_identifiers(self):
+    def geometry_identifiers(self) -> Dict[str, str]:
         """
-        Look up geometries by identifier hash
+        Look up geometries by identifier hash.
 
         Returns
         ---------
-        identifiers : dict
+        identifiers
           {Identifier hash: key in self.geometry}
         """
         identifiers = {mesh.identifier_hash: name for name, mesh in self.geometry.items()}
         return identifiers
 
     @caching.cache_decorator
-    def duplicate_nodes(self):
+    def duplicate_nodes(self) -> List[List[str]]:
         """
         Return a sequence of node keys of identical meshes.
 
@@ -573,7 +580,7 @@ class Scene(Geometry3D):
 
         Returns
         -----------
-        duplicates : (m) sequence
+        duplicates
           Keys of self.graph that represent identical geometry
         """
         # if there is no geometry we can have no duplicate nodes
@@ -606,7 +613,7 @@ class Scene(Geometry3D):
         # we only care about the values keys are garbage
         return list(duplicates.values())
 
-    def deduplicated(self):
+    def deduplicated(self) -> "Scene":
         """
         Return a new scene where each unique geometry is only
         included once and transforms are discarded.
@@ -629,7 +636,7 @@ class Scene(Geometry3D):
 
     def set_camera(
         self, angles=None, distance=None, center=None, resolution=None, fov=None
-    ):
+    ) -> None:
         """
         Create a camera object for self.camera, and add
         a transform to self.graph for it.
@@ -691,7 +698,7 @@ class Scene(Geometry3D):
         return self.graph[self.camera.name][0]
 
     @camera_transform.setter
-    def camera_transform(self, matrix):
+    def camera_transform(self, matrix: NDArray[float64]):
         """
         Set the camera transform in the base frame
 
@@ -702,7 +709,7 @@ class Scene(Geometry3D):
         """
         self.graph[self.camera.name] = matrix
 
-    def camera_rays(self):
+    def camera_rays(self) -> Tuple[NDArray[float64], NDArray[float64], NDArray[int64]]:
         """
         Calculate the trimesh.scene.Camera origin and ray
         direction vectors. Returns one ray per pixel as set
@@ -730,7 +737,7 @@ class Scene(Geometry3D):
         return origins, vectors, pixels
 
     @property
-    def camera(self):
+    def camera(self) -> cameras.Camera:
         """
         Get the single camera for the scene. If not manually
         set one will abe automatically generated.
@@ -749,7 +756,7 @@ class Scene(Geometry3D):
         return self._camera
 
     @camera.setter
-    def camera(self, camera):
+    def camera(self, camera: cameras.Camera):
         """
         Set a camera object for the Scene.
 
@@ -763,11 +770,11 @@ class Scene(Geometry3D):
         self._camera = camera
 
     @property
-    def has_camera(self):
+    def has_camera(self) -> bool:
         return hasattr(self, "_camera") and self._camera is not None
 
     @property
-    def lights(self):
+    def lights(self) -> List[lighting.Light]:
         """
         Get a list of the lights in the scene. If nothing is
         set it will generate some automatically.
@@ -788,7 +795,7 @@ class Scene(Geometry3D):
         return self._lights
 
     @lights.setter
-    def lights(self, lights):
+    def lights(self, lights: List[lighting.Light]):
         """
         Assign a list of light objects to the scene
 
@@ -799,7 +806,7 @@ class Scene(Geometry3D):
         """
         self._lights = lights
 
-    def rezero(self):
+    def rezero(self) -> None:
         """
         Move the current scene so that the AABB of the whole
         scene is centered at the origin.
@@ -822,13 +829,13 @@ class Scene(Geometry3D):
         )
         self.graph.base_frame = new_base
 
-    def dump(self, concatenate=False):
+    def dump(self, concatenate: bool = False) -> Union["Scene", List[Geometry]]:
         """
         Append all meshes in scene freezing transforms.
 
         Parameters
         ------------
-        concatenate : bool
+        concatenate
           If True, concatenate results into single mesh
 
         Returns
@@ -870,18 +877,18 @@ class Scene(Geometry3D):
 
         return np.array(result)
 
-    def subscene(self, node):
+    def subscene(self, node: str) -> "Scene":
         """
         Get part of a scene that succeeds a specified node.
 
         Parameters
         ------------
-        node : any
+        node
           Hashable key in `scene.graph`
 
         Returns
         -----------
-        subscene : Scene
+        subscene
           Partial scene generated from current.
         """
         # get every node that is a successor to specified node
@@ -903,11 +910,12 @@ class Scene(Geometry3D):
     @caching.cache_decorator
     def convex_hull(self):
         """
-        The convex hull of the whole scene
+        The convex hull of the whole scene.
 
         Returns
         ---------
-        hull: Trimesh object, convex hull of all meshes in scene
+        hull : trimesh.Trimesh
+          Trimesh object which is a convex hull of all meshes in scene
         """
         points = util.vstack_empty([m.vertices for m in self.dump()])
         hull = convex.convex_hull(points)
@@ -934,7 +942,7 @@ class Scene(Geometry3D):
             scene=self, file_obj=file_obj, file_type=file_type, **kwargs
         )
 
-    def save_image(self, resolution=None, **kwargs):
+    def save_image(self, resolution=None, **kwargs) -> bytes:
         """
         Get a PNG image of a scene.
 
@@ -952,8 +960,7 @@ class Scene(Geometry3D):
         """
         from ..viewer.windowed import render_scene
 
-        png = render_scene(scene=self, resolution=resolution, **kwargs)
-        return png
+        return render_scene(scene=self, resolution=resolution, **kwargs)
 
     @property
     def units(self) -> Optional[str]:
@@ -1031,9 +1038,9 @@ class Scene(Geometry3D):
 
         return result
 
-    def explode(self, vector=None, origin=None):
+    def explode(self, vector=None, origin=None) -> None:
         """
-        Explode a scene around a point and vector.
+        Explode the current scene in-place around a point and vector.
 
         Parameters
         -----------
@@ -1070,7 +1077,7 @@ class Scene(Geometry3D):
             T_new[:3, 3] += offset
             self.graph[node_name] = T_new
 
-    def scaled(self, scale):
+    def scaled(self, scale: Union[float, NDArray[float64]]) -> "Scene":
         """
         Return a copy of the current scene, with meshes and scene
         transforms scaled to the requested factor.
@@ -1198,7 +1205,7 @@ class Scene(Geometry3D):
                     )
         return result
 
-    def copy(self):
+    def copy(self) -> "Scene":
         """
         Return a deep copy of the current scene
 
diff --git a/trimesh/transformations.py b/trimesh/transformations.py
index 4c2046c3..44c92aa1 100644
--- a/trimesh/transformations.py
+++ b/trimesh/transformations.py
@@ -1124,12 +1124,21 @@ def euler_matrix(ai, aj, ak, axes="sxyz"):
     if parity:
         ai, aj, ak = -ai, -aj, -ak
 
-    si, sj, sk = np.sin(ai), np.sin(aj), np.sin(ak)
-    ci, cj, ck = np.cos(ai), np.cos(aj), np.cos(ak)
+    if type(ai).__name__ == "Symbol":
+        # if we have been passed input values as sympy.Symbol
+        # use symbolic cosine and identity matrix
+        from sympy import cos, eye, sin
+
+        M = eye(4)
+    else:
+        sin, cos = np.sin, np.cos
+        M = np.eye(4)
+
+    si, sj, sk = sin(ai), sin(aj), sin(ak)
+    ci, cj, ck = cos(ai), cos(aj), cos(ak)
     cc, cs = ci * ck, ci * sk
     sc, ss = si * ck, si * sk
 
-    M = np.identity(4)
     if repetition:
         M[i, i] = cj
         M[i, j] = sj * si
diff --git a/trimesh/visual/gloss.py b/trimesh/visual/gloss.py
index 7f26442e..21a2b061 100644
--- a/trimesh/visual/gloss.py
+++ b/trimesh/visual/gloss.py
@@ -299,7 +299,7 @@ def specular_to_pbr(
         metallic = np.array(metallic, dtype=np.float32)
 
     diffuse_rgb = diffuse[..., :3]
-    opacity = diffuse[..., -1] if diffuse.shape[-1] == 4 else None
+
     base_color_from_diffuse = diffuse_rgb * (
         one_minus_specular_strength
         / (1.0 - dielectric_specular[0])
@@ -312,8 +312,29 @@ def specular_to_pbr(
     base_color = mm * base_color_from_specular + (1.0 - mm) * base_color_from_diffuse
     base_color = np.clip(base_color, 0.0, 1.0)
 
-    if opacity is not None and np.any(opacity < 1.0):
-        base_color = np.concatenate([base_color, opacity[..., None]], axis=-1)
+    # get opacity
+    try:
+        if diffuse.shape == (4,):
+            # opacity is a single scalar value
+            opacity = diffuse[-1]
+            if base_color.shape == (3,):
+                # simple case with one color and diffuse with opacity
+                # add on the opacity from the diffuse color
+                base_color = np.append(base_color, opacity)
+            elif len(base_color.shape) == 3:
+                # stack opacity to match the base color array
+                dim = base_color.shape
+                base_color = np.dstack(
+                    (
+                        base_color,
+                        np.full(np.prod(dim[:2]), opacity).reshape((dim[0], dim[1], 1)),
+                    )
+                )
+        elif diffuse.shape[-1] == 4:
+            opacity = diffuse[..., -1]
+            base_color = np.concatenate([base_color, opacity[..., None]], axis=-1)
+    except BaseException:
+        log.error("unable to get opacity", exc_info=True)
 
     result = {}
     if len(base_color.shape) > 1:
diff --git a/trimesh/visual/objects.py b/trimesh/visual/objects.py
index 8c6bb602..f6166259 100644
--- a/trimesh/visual/objects.py
+++ b/trimesh/visual/objects.py
@@ -79,8 +79,8 @@ def concatenate(visuals, *args):
         new_mat, new_uv = pack(materials=mats, uvs=uvs)
         return TextureVisuals(material=new_mat, uv=new_uv)
 
-    # convert all visuals to the kind of the first
-    kind = visuals[0].kind
+    # convert all visuals to the first valid kind
+    kind = next((v.kind for v in visuals if v.kind is not None), None)
     if kind == "face":
         colors = np.vstack([v.face_colors for v in visuals])
         return ColorVisuals(face_colors=colors)


Test patch:
diff --git a/tests/test_color.py b/tests/test_color.py
index 83e95baa..d19f26e7 100644
--- a/tests/test_color.py
+++ b/tests/test_color.py
@@ -28,6 +28,34 @@ class VisualTest(g.unittest.TestCase):
         r = a + b
         assert any(r.visual.face_colors.ptp(axis=0) > 1)
 
+    def test_concatenate_empty_mesh(self):
+        box = g.get_mesh("box.STL")
+
+        mesh_fcolor = box.copy()
+        mesh_fcolor.visual.face_colors = [255, 0, 0]
+
+        mesh_vcolor = box.copy()
+        mesh_vcolor.visual.vertex_colors = [0, 0, 255]
+
+        mesh_empty = g.trimesh.Trimesh()
+
+        r_left_fcolor = mesh_fcolor + mesh_empty
+        r_right_fcolor = mesh_empty + mesh_fcolor
+        r_left_vcolor = mesh_vcolor + mesh_empty
+        r_right_vcolor = mesh_empty + mesh_vcolor
+        r_empty = mesh_empty + mesh_empty
+
+        for visual_face in [r_left_fcolor.visual, r_right_fcolor.visual]:
+            assert (visual_face.face_colors == mesh_fcolor.visual.face_colors).all()
+            assert visual_face.kind == "face"
+
+        for visual_vert in [r_left_vcolor.visual, r_right_vcolor.visual]:
+            assert (visual_vert.vertex_colors == mesh_vcolor.visual.vertex_colors).all()
+            assert visual_vert.kind == "vertex"
+
+        assert len(r_empty.visual.face_colors) == 0
+        assert r_empty.visual.kind is None
+
     def test_data_model(self):
         """
         Test the probably too- magical color caching and storage
diff --git a/tests/test_gltf.py b/tests/test_gltf.py
index 5cb41a4e..2261f8c1 100644
--- a/tests/test_gltf.py
+++ b/tests/test_gltf.py
@@ -1019,6 +1019,19 @@ class GLTFTest(g.unittest.TestCase):
             r = g.trimesh.load(path)
             assert g.np.isclose(r.volume, 1.0)
 
+    def test_postprocess(self):
+        # check to see if keys we expect exist
+        s = g.get_mesh("cycloidal.3DXML")
+
+        def post(tree):
+            # should have exported meshes here
+            assert len(tree["meshes"]) == len(s.geometry)
+            # should have buffers
+            assert len(tree["buffers"]) >= 1
+
+        # export with a postprocessor
+        s.export(file_type="glb", tree_postprocessor=post)
+
 
 if __name__ == "__main__":
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_stl.py b/tests/test_stl.py
index 2cc48187..144a4c47 100644
--- a/tests/test_stl.py
+++ b/tests/test_stl.py
@@ -69,7 +69,7 @@ class STLTests(g.unittest.TestCase):
     def test_ascii_multibody(self):
         s = g.get_mesh("multibody.stl")
         assert len(s.geometry) == 2
-        assert set(s.geometry.keys()) == {"bodya", "bodyb"}
+        assert set(s.geometry.keys()) == {"bodyA", "bodyB"}
 
     def test_empty(self):
         # demo files to check
diff --git a/tests/test_transformations.py b/tests/test_transformations.py
index a0cc07f0..1d54c310 100644
--- a/tests/test_transformations.py
+++ b/tests/test_transformations.py
@@ -184,6 +184,49 @@ class TransformTest(g.unittest.TestCase):
             0.0,
         )
 
+    def test_symbolic_rotation(self):
+        # you can pass `sympy.Symbol` to `trimesh.transformation.rotation_matrix`
+        try:
+            import sympy as sp
+        except BaseException:
+            return
+        tf = g.trimesh.transformations
+
+        a = sp.Symbol("a")
+        vector = [1, 1, 1]
+        m = tf.rotation_matrix(a, vector)
+        for v in [0.0, 1.1, 1.234, g.np.pi]:
+            # evaluate the symbolic matrix with a value
+            s = g.np.array(m.subs({a: v}).evalf(), dtype=g.np.float64)
+            # call rotation matrix with a scalar
+            n = tf.rotation_matrix(v, vector)
+
+            # they should be the same matrix
+            assert g.np.allclose(s, n)
+
+    def test_symbolic_euler(self):
+        # some of the functions have been modified to support `sympy.Symbol`
+        # values which is useful for calculating final rotations symbolically
+        try:
+            import sympy as sp
+        except BaseException:
+            return
+
+        euler = g.trimesh.transformations.euler_matrix
+
+        ra, rb, rc = sp.symbols("ra rb rc")
+        m = euler(ra, rb, rc)
+        for rot in g.random((100, 3)):
+            # get the euler matrix evaluated from the symbolic matrix
+            s = g.np.array(
+                m.subs({ra: rot[0], rb: rot[1], rc: rot[2]}).evalf(), dtype=g.np.float64
+            )
+            # get it from a numeric scalar
+            n = euler(*rot)
+
+            # they should be the same matrix
+            assert g.np.allclose(s, n)
+
 
 if __name__ == "__main__":
     g.trimesh.util.attach_to_log()