Repository: mikedh/trimesh

Issue:
BUG: Trimesh.visual.face_colors isn't added when adding an existing mesh to an empty mesh
Hi. First let me say that this repo is amazing and I use it a lot, so THANKS!!

It appears that there's a small bug when adding an existing mesh with face_colors to an empty mesh, where the face colors are not preserved. 
Here is a complete repro code:
```python
import trimesh

# Define 4 vertices
vertices = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]  # Vertex 0  # Vertex 1  # Vertex 2  # Vertex 3

# Define 2 triangular faces
faces = [[0, 1, 2], [0, 2, 3]]  # Face 0  # Face 1

# Create the mesh
mesh1 = trimesh.Trimesh(vertices=vertices, faces=faces)

mesh1.visual.face_colors = [
    [0, 0, 0, 255],
    [0, 0, 0, 255],
]
# Print the mesh details
print(mesh1)
print(mesh1.visual.face_colors)
# ==== output
# <trimesh.Trimesh(vertices.shape=(4, 3), faces.shape=(2, 3))>
# [[  0   0   0 255]
#  [  0   0   0 255]]


# Add the existing mesh with face_colors to an empty mesh
mesh2 = trimesh.Trimesh()
mesh2 += mesh1
print(mesh2.visual.face_colors)
# ==== output
# [[102 102 102 255]
#  [102 102 102 255]]
```
trimesh load_stl_ascii converts the text to lowercase, changing names of individual solids in file
Hello,
A Good day to you. 

We use ASCII STL files with multiple solids within the file for preparing geometry for CFD simulations. The workflow uses solid names which are case sensitive. 

When reading in these files using trimesh, the module `load_stl_ascii(file_obj)` in the module trimesh.exchange.stl, the following line converts all the text to lower case...:

`    raw = util.decode_text(file_obj.read()).strip().lower()` (line 158)

The names of the solids are extracted after this conversion.

When programmatically writing configuration files using data extracted through trimesh, this leads to errors in the naming of the solids, hence breaking the workflow downstream. 

Keeping the names of the solids as there are in the STL file would be an important requirement for consistent use of the library. 

Thank you very much. 
Consider moving the glb tree_postprocessor after creating bufferViews?
Hey Mike,

I have been playing around with animations in GLB lately, and I came across parts of the GLTF spec which in certain occasions requires a "target" property on the BUFFER_VIEW objects ([spec](https://github.com/KhronosGroup/glTF-Tutorials/blob/master/gltfTutorial/gltfTutorial_005_BuffersBufferViewsAccessors.md#bufferviews))

Here's the error message I got from the khronos GLTF validator:
```json
            {
                "code": "BUFFER_VIEW_TARGET_MISSING",
                "message": "bufferView.target should be set for vertex or index data.",
                "severity": 3,
                "pointer": "/meshes/0/primitives/0/attributes/POSITION"
            },
```

ie. I believe it is required to write something along the lines of 
```
"bufferViews": [
  {
    "buffer": 0,
    "byteOffset": 0,
    "byteLength": 1234,
    "target": 34962  // ARRAY_BUFFER for vertex attributes
  },
  {
    "buffer": 0,
    "byteOffset": 1234,
    "byteLength": 567,
    "target": 34963  // ELEMENT_ARRAY_BUFFER for indices
  }
  // ... other buffer views
]
```

Before attempting to make any permanent changes to the bufferView writer to automatically add the target property, I was thinking that a quick fix was to just `move` the [tree_postprocessor](https://github.com/mikedh/trimesh/blob/80a6c1f3300f360b318acfde1fdd1bb2e49942d4/trimesh/exchange/gltf.py#L206-L228) a few lines down

```python
    # allow custom postprocessing
    if tree_postprocessor is not None:
        tree_postprocessor(tree)   <-- From Here


    # A bufferView is a slice of a file
    views = _build_views(buffer_items)


    # combine bytes into a single blob
    buffer_data = b"".join(buffer_items.values())


    # add the information about the buffer data
    if len(buffer_data) > 0:
        tree["buffers"] = [{"byteLength": len(buffer_data)}]
        tree["bufferViews"] = views


# --> How about moving it here? <--

    # export the tree to JSON for the header
    content = util.jsonify(tree, separators=(",", ":"))
    # add spaces to content, so the start of the data
    # is 4 byte aligned as per spec
    content += (4 - ((len(content) + 20) % 4)) * " "
    content = content.encode("utf-8")
    # make sure we didn't screw it up
    assert (len(content) % 4) == 0
    
```

I checked the tests and all gltf tests seems unaffected:

The only failing test was `test_bulk` which seems unrelated (missing import on my end perhaps?):

```python
    if isinstance(geom, g.trimesh.voxel.VoxelGrid):
                        ^^^^^^^^^^^^^^^
AttributeError: module 'trimesh' has no attribute 'voxel'
```

Any thoughts?


Best Regards
Kristoffer


Discussion:
this is the current workaround:
```python
    concat_v = [np.zeros((0, 3))]
    concat_f = [np.zeros((0, 3))]
    concat_f_colors = [np.zeros((0, 4))]
    num_v = 0
    for path_name in group:
        tmp_mesh = trimesh.load(path_name)
        concat_v.append(tmp_mesh.vertices)
        concat_f.append(tmp_mesh.faces + num_v)
        concat_f_colors.append(tmp_mesh.visual.face_colors)
        num_v += tmp_mesh.vertices.shape[0]

    mesh = trimesh.Trimesh()
    mesh.vertices = np.concatenate(concat_v, axis=0)
    mesh.faces = np.concatenate(concat_f, axis=0)
    mesh.visual.face_colors = np.concatenate(concat_f_colors, axis=0)
``` 







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 000000000..ce8a65585
--- /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 000000000..ebbe27b3b
--- /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 a26764d01..09ec756e3 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 2f3740059..8c8b2d5a8 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -528,7 +528,7 @@ def vertex_faces(self) -> NDArray[int64]:
         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 @@ def bounds(self) -> NDArray[float64]:
         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 f2670e71c..72de72039 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 a979c345a..57e763cbb 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 8961d7373..c9abe1f11 100644
--- a/trimesh/scene/scene.py
+++ b/trimesh/scene/scene.py
@@ -5,8 +5,8 @@
 
 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 @@ def __init__(
         -------------
         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 @@ def apply_transform(self, transform):
 
     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 @@ def add_geometry(
 
         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 @@ def delete_geometry(self, names):
         # 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 @@ def strip_visuals(self):
             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 @@ def __hash__(self):
         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 @@ def is_valid(self):
             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 @@ def bounds_corners(self):
         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 @@ def bounds(self):
         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 @@ def scale(self):
         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 @@ def centroid(self):
         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 @@ def moment_inertia_frame(self, transform):
         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 @@ def area(self):
         )
 
     @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 @@ def volume(self):
         )
 
     @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 @@ def triangles_node(self):
         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 @@ def duplicate_nodes(self):
 
         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 @@ def duplicate_nodes(self):
         # 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 @@ def deduplicated(self):
 
     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 @@ def camera_transform(self):
         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 @@ def camera_transform(self, matrix):
         """
         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 @@ def camera_rays(self):
         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 @@ def camera(self):
         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 @@ def camera(self, camera):
         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 @@ def lights(self):
         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 @@ def lights(self, lights):
         """
         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 @@ def rezero(self):
         )
         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 @@ def dump(self, concatenate=False):
 
         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 @@ def subscene(self, node):
     @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 @@ def export(self, file_obj=None, file_type=None, **kwargs):
             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 @@ def save_image(self, resolution=None, **kwargs):
         """
         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 @@ def convert_units(self, desired: str, guess: bool = False) -> "Scene":
 
         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 @@ def explode(self, vector=None, origin=None):
             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 @@ def scaled(self, scale):
                     )
         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 4c2046c31..44c92aa14 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 7f26442ef..21a2b0613 100644
--- a/trimesh/visual/gloss.py
+++ b/trimesh/visual/gloss.py
@@ -299,7 +299,7 @@ def convert_texture_lin2srgb(texture):
         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 convert_texture_lin2srgb(texture):
     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 8c6bb6029..f6166259a 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 83e95baad..d19f26e7f 100644
--- a/tests/test_color.py
+++ b/tests/test_color.py
@@ -28,6 +28,34 @@ def test_concatenate(self):
         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 5cb41a4e5..2261f8c1f 100644
--- a/tests/test_gltf.py
+++ b/tests/test_gltf.py
@@ -1019,6 +1019,19 @@ def test_relative_paths(self):
             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 2cc48187a..144a4c470 100644
--- a/tests/test_stl.py
+++ b/tests/test_stl.py
@@ -69,7 +69,7 @@ def test_attrib(self):
     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 a0cc07f0a..1d54c310f 100644
--- a/tests/test_transformations.py
+++ b/tests/test_transformations.py
@@ -184,6 +184,49 @@ def test_angle(self):
             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()