Repository: mikedh/trimesh
Pull request #2424 (2025-07-07 21:00:41)

Robustify DAE shininess parsing
I found a lot of online .dae files with invalid `shininess` values. Parsers like `MeshLab` successfully ignore these values and I think `trimesh` should do the same.

As an example, the files from [ArtVIP](https://huggingface.co/datasets/x-humanoid-robomind/ArtVIP) contain these invalid shininess values:
```xml
<shininess>
  <color>0.000000</color>
</shininess>
```
The original code failed with a runtime error: `TypeError: unsupported operand type(s) for +: 'float' and 'tuple'`.
The updated code succeeds and prints the following: `Invalid shininess value: (0.0, 0.0, 0.0, 1.0), using default roughness`


Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 518e542c..1c7b282a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -15,7 +15,7 @@ jobs:
     - name: Set up Python
       uses: actions/setup-python@v5
       with:
-        python-version: "3.13"
+        python-version: "3.12"
     - name: Install Formatting
       run: |
         pip install ruff
@@ -63,7 +63,7 @@ jobs:
     - name: Set up Python
       uses: actions/setup-python@v5
       with:
-        python-version: '3.13'
+        python-version: '3.12'
     - name: Install publishing dependencies
       run: pip install build
     - name: Build
diff --git a/models/cube_invalid_shininess.dae b/models/cube_invalid_shininess.dae
new file mode 100644
index 00000000..3a1a6348
--- /dev/null
+++ b/models/cube_invalid_shininess.dae
@@ -0,0 +1,88 @@
+<?xml version="1.0" encoding="utf-8"?>
+<COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
+  <library_effects>
+    <effect id="Material-effect">
+      <profile_COMMON>
+        <technique sid="common">
+          <phong>
+            <emission>
+              <color sid="emission">0 0 0 1</color>
+            </emission>
+            <ambient>
+              <color sid="ambient">0 0 0 1</color>
+            </ambient>
+            <diffuse>
+              <color sid="diffuse">0 0.05155162 0.3791305 1</color>
+            </diffuse>
+            <specular>
+              <color sid="specular">0.5 0.5 0.5 1</color>
+            </specular>
+            <shininess>
+              <color>0.000000</color>
+            </shininess>
+            <index_of_refraction>
+              <float sid="index_of_refraction">1</float>
+            </index_of_refraction>
+          </phong>
+        </technique>
+      </profile_COMMON>
+    </effect>
+  </library_effects>
+  <library_materials>
+    <material id="Material-material" name="Material">
+      <instance_effect url="#Material-effect"/>
+    </material>
+  </library_materials>
+  <library_geometries>
+    <geometry id="Cube-mesh" name="Cube">
+      <mesh>
+        <source id="Cube-mesh-positions">
+          <float_array id="Cube-mesh-positions-array" count="24">1 1 -1 1 -1 -1 -1 -0.9999998 -1 -0.9999997 1 -1 1 0.9999995 1 0.9999994 -1.000001 1 -1 -0.9999997 1 -1 1 1</float_array>
+          <technique_common>
+            <accessor source="#Cube-mesh-positions-array" count="8" stride="3">
+              <param name="X" type="float"/>
+              <param name="Y" type="float"/>
+              <param name="Z" type="float"/>
+            </accessor>
+          </technique_common>
+        </source>
+        <source id="Cube-mesh-normals">
+          <float_array id="Cube-mesh-normals-array" count="36">0 0 -1 0 0 1 1 0 -2.38419e-7 0 -1 -4.76837e-7 -1 2.38419e-7 -1.49012e-7 2.68221e-7 1 2.38419e-7 0 0 -1 0 0 1 1 -5.96046e-7 3.27825e-7 -4.76837e-7 -1 0 -1 2.38419e-7 -1.19209e-7 2.08616e-7 1 0</float_array>
+          <technique_common>
+            <accessor source="#Cube-mesh-normals-array" count="12" stride="3">
+              <param name="X" type="float"/>
+              <param name="Y" type="float"/>
+              <param name="Z" type="float"/>
+            </accessor>
+          </technique_common>
+        </source>
+        <vertices id="Cube-mesh-vertices">
+          <input semantic="POSITION" source="#Cube-mesh-positions"/>
+        </vertices>
+        <triangles material="Material-material" count="12">
+          <input semantic="VERTEX" source="#Cube-mesh-vertices" offset="0"/>
+          <input semantic="NORMAL" source="#Cube-mesh-normals" offset="1"/>
+          <p>0 0 2 0 3 0 7 1 5 1 4 1 4 2 1 2 0 2 5 3 2 3 1 3 2 4 7 4 3 4 0 5 7 5 4 5 0 6 1 6 2 6 7 7 6 7 5 7 4 8 5 8 1 8 5 9 6 9 2 9 2 10 6 10 7 10 0 11 3 11 7 11</p>
+        </triangles>
+      </mesh>
+    </geometry>
+  </library_geometries>
+  <library_controllers/>
+  <library_visual_scenes>
+    <visual_scene id="Scene" name="Scene">
+      <node id="Cube" name="Cube" type="NODE">
+        <matrix sid="transform">1 0 0 -0.1477693 0 1 0 -0.2537472 0 0 1 0.06556333 0 0 0 1</matrix>
+        <instance_geometry url="#Cube-mesh" name="Cube">
+          <bind_material>
+            <technique_common>
+              <instance_material symbol="Material-material" target="#Material-material"/>
+            </technique_common>
+          </bind_material>
+        </instance_geometry>
+      </node>
+    </visual_scene>
+  </library_visual_scenes>
+  <scene>
+    <instance_visual_scene url="#Scene"/>
+  </scene>
+</COLLADA>
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 35e47e65..3a089307 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -73,8 +73,7 @@ easy = [
     "jsonschema",
     "networkx",
     "svg.path",
-    "pycollada <= 0.9.0; python_version<'3.9'",
-    "pycollada; python_version>='3.9'",
+    "pycollada",
     "shapely",
     "xxhash",
     "rtree",
@@ -123,8 +122,9 @@ test_more = [
     "marimo",
 ]
 
+# interfaces.gmsh will be dropped sometime after January 2025
 # openctm will be fully removed sometime after June 2026
-deprecated = ["openctm"]
+deprecated = ["gmsh==4.12.2","openctm"]
 
 # requires pip >= 21.2
 # https://hynek.me/articles/python-recursive-optional-dependencies/
@@ -175,3 +175,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", "var-annotated"]
diff --git a/trimesh/exchange/dae.py b/trimesh/exchange/dae.py
index b46732ff..d24798b5 100644
--- a/trimesh/exchange/dae.py
+++ b/trimesh/exchange/dae.py
@@ -327,7 +327,13 @@ def _parse_material(effect, resolver):
         not isinstance(effect.shininess, collada.material.Map)
         and effect.shininess is not None
     ):
-        roughnessFactor = np.sqrt(2.0 / (2.0 + effect.shininess))
+        try:
+            shininess_value = float(effect.shininess)
+            roughnessFactor = np.sqrt(2.0 / (2.0 + shininess_value))
+        except (TypeError, ValueError):
+            log.warning(
+                f"Invalid shininess value: {effect.shininess}, using default roughness"
+            )
 
     # Compute metallic factor
     metallicFactor = 0.0
diff --git a/trimesh/exchange/obj.py b/trimesh/exchange/obj.py
index 9e345f02..81bb87db 100644
--- a/trimesh/exchange/obj.py
+++ b/trimesh/exchange/obj.py
@@ -425,7 +425,7 @@ def _parse_faces_vectorized(array, columns, sample_line):
     # TODO: probably need to support 8 and 12 columns for quads
     # or do something more general
     faces_tex, faces_norm = None, None
-    if columns == group_count * 2:
+    if columns == 6:
         # if we have two values per vertex the second
         # one is index of texture coordinate (`vt`)
         # count how many delimiters are in the first face line
@@ -444,12 +444,25 @@ def _parse_faces_vectorized(array, columns, sample_line):
             faces_tex = array[:, index + 1]
         else:
             log.debug(f"face lines are weird: {sample_line}")
-    elif columns == group_count * 3:
+    elif columns == 9:
         # if we have three values per vertex
         # second value is always texture
         faces_tex = array[:, index + 1]
         # third value is reference to vertex normal (`vn`)
         faces_norm = array[:, index + 2]
+    elif columns == 8:
+        # handle quad faces with vertex/texture format
+        # case where each face line looks like:
+        # '75/139 76/141 77/141 78/142'
+        # which is vertex/texture for quads
+        faces_tex = array[:, index + 1]
+    elif columns == 12:
+        # handle quad faces with vertex/texture/normal format
+        # case where each face line looks like:
+        # '75/139/1 76/141/2 77/141/3 78/142/4'
+        # which is vertex/texture/normal for quads
+        faces_tex = array[:, index + 1]
+        faces_norm = array[:, index + 2]
     return faces, faces_tex, faces_norm
 
 
diff --git a/trimesh/interfaces/__init__.py b/trimesh/interfaces/__init__.py
index 523f2db0..4f58ef4a 100644
--- a/trimesh/interfaces/__init__.py
+++ b/trimesh/interfaces/__init__.py
@@ -1,3 +1,4 @@
-from . import blender
+from . import blender, gmsh
 
-__all__ = ["blender"]
+# add to __all__ as per pep8
+__all__ = ["blender", "gmsh"]
diff --git a/trimesh/interfaces/gmsh.py b/trimesh/interfaces/gmsh.py
new file mode 100644
index 00000000..d70f6bdd
--- /dev/null
+++ b/trimesh/interfaces/gmsh.py
@@ -0,0 +1,283 @@
+import os
+import tempfile
+import warnings
+
+import numpy as np
+
+from ..constants import log
+from ..typed import Integer, Number, Optional
+
+# the old `gmsh-sdk` package is deprecated and
+# has a different incompatible API!
+_min_gmsh = (4, 12, 1)
+
+
+_warning = " ".join(
+    [
+        "`trimesh.interfaces.gmsh` is deprecated and will be removed January 2025!",
+        "There are *many* gmsh options on PyPi: `scikit-gmsh` `gmsh` `pygmsh` `gmsh-sdk`,",
+        "users should pick one of those and use it directly. If STEP loading is the only",
+        "thing needed you may want `pip install cascadio` which uses OpenCASCADE more",
+        "directly and will immediately enable STEP as a loadable format in trimesh.",
+    ]
+)
+
+
+def load_gmsh(file_name, gmsh_args=None, interruptible=True):
+    """
+    Returns a surface mesh from CAD model in Open Cascade
+    Breap (.brep), Step (.stp or .step) and Iges formats
+    Or returns a surface mesh from 3D volume mesh using gmsh.
+
+    For a list of possible options to pass to GMSH, check:
+    http://gmsh.info/doc/texinfo/gmsh.html
+
+    An easy way to install the GMSH SDK is through the `gmsh`
+    package on PyPi, which downloads and sets up gmsh:
+        >>> pip install gmsh
+
+    Parameters
+    --------------
+    file_name : str
+      Location of the file to be imported
+    gmsh_args : (n, 2) list
+      List of (parameter, value) pairs to be passed to
+      gmsh.option.setNumber
+    max_element : float or None
+      Maximum length of an element in the volume mesh
+    interruptible : bool
+      Allows load_gmsh to run outside of the main thread if False,
+      default behaviour if set to True. Added in 4.12.0
+
+    Returns
+    ------------
+    mesh : trimesh.Trimesh
+      Surface mesh of input geometry
+    """
+    warnings.warn(_warning, category=DeprecationWarning, stacklevel=2)
+
+    # use STL as an intermediate format
+    # do import here to avoid very occasional segfaults
+    import gmsh
+
+    # the deprecated `pip install gmsh-sdk` package has an entirely different API
+    # require a minimum version here
+    if tuple(int(i) for i in gmsh.__version__.split(".")) < _min_gmsh:
+        raise ImportError(
+            f"`gmsh.__version__ < {_min_gmsh}`: run `pip install --upgrade gmsh`"
+        )
+
+    from ..exchange.stl import load_stl
+
+    # start with default args for the meshing step
+    # Mesh.Algorithm=2 MeshAdapt/Delaunay, there are others but they may include quads
+    # With this planes are meshed using Delaunay and cylinders are meshed
+    # using MeshAdapt
+    args = [
+        ("Mesh.Algorithm", 2),
+        ("Mesh.CharacteristicLengthFromCurvature", 1),
+        ("Mesh.MinimumCirclePoints", 32),
+    ]
+    # add passed argument tuples last so we can override defaults
+    if gmsh_args is not None:
+        args.extend(gmsh_args)
+
+    # formats GMSH can load
+    supported = [
+        ".brep",
+        ".stp",
+        ".step",
+        ".igs",
+        ".iges",
+        ".bdf",
+        ".msh",
+        ".inp",
+        ".diff",
+        ".mesh",
+    ]
+
+    # check extensions to make sure it is supported format
+    if file_name is not None:
+        if not any(file_name.lower().endswith(e) for e in supported):
+            raise ValueError(
+                "Supported formats are: BREP (.brep), STEP (.stp or .step), "
+                + "IGES (.igs or .iges), Nastran (.bdf), Gmsh (.msh), Abaqus (*.inp), "
+                + "Diffpack (*.diff), Inria Medit (*.mesh)"
+            )
+    else:
+        raise ValueError("No import since no file was provided!")
+
+    # hmmm
+    init = False
+    try:
+        if hasattr(gmsh, "isInitialized"):
+            init = gmsh.isInitialized()
+        elif hasattr(gmsh, "is_initialized"):
+            init = gmsh.is_initialized()
+    except BaseException:
+        log.debug("gmsh unexpected", exc_info=True)
+
+    if not init:
+        gmsh.initialize(interruptible=interruptible)
+
+    gmsh.option.setNumber("General.Terminal", 1)
+    gmsh.model.add("Surface_Mesh_Generation")
+    # loop through our numbered args which do things, stuff
+    for arg in args:
+        gmsh.option.setNumber(*arg)
+
+    gmsh.open(file_name)
+
+    # create a temporary file for the results
+    out_data = tempfile.NamedTemporaryFile(suffix=".stl", delete=False)
+    # windows gets mad if two processes try to open the same file
+    out_data.close()
+
+    # we have to mesh the surface as these are analytic BREP formats
+    if any(
+        file_name.lower().endswith(e) for e in [".brep", ".stp", ".step", ".igs", ".iges"]
+    ):
+        gmsh.model.geo.synchronize()
+        # generate the mesh
+        gmsh.model.mesh.generate(2)
+        # write to the temporary file
+        gmsh.write(out_data.name)
+    else:
+        gmsh.plugin.run("NewView")
+        gmsh.plugin.run("Skin")
+        gmsh.view.write(1, out_data.name)
+
+    # load the data from the temporary outfile
+    with open(out_data.name, "rb") as f:
+        kwargs = load_stl(f)
+
+    gmsh.finalize()
+
+    return kwargs
+
+
+def to_volume(
+    mesh,
+    file_name: Optional[str] = None,
+    file_type: Optional[str] = None,
+    max_element: Optional[Number] = None,
+    mesher_id: Integer = 1,
+) -> bytes:
+    """
+    Convert a surface mesh to a 3D volume mesh generated by gmsh.
+
+    An easy way to install the gmsh sdk is through the gmsh
+    package on pypi, which downloads and sets up gmsh:
+        pip install gmsh
+
+    Algorithm details, although check gmsh docs for more information:
+    The "Delaunay" algorithm is split into three separate steps.
+    First, an initial mesh of the union of all the volumes in the model is performed,
+    without inserting points in the volume. The surface mesh is then recovered using H.
+    Si's boundary recovery algorithm Tetgen/BR. Then a three-dimensional version of the
+    2D Delaunay algorithm described above is applied to insert points in the volume to
+    respect the mesh size constraints.
+
+    The Frontal" algorithm uses J. Schoeberl's Netgen algorithm.
+    The "HXT" algorithm is a new efficient and parallel reimplementaton
+    of the Delaunay algorithm.
+    The "MMG3D" algorithm (experimental) allows to generate
+    anisotropic tetrahedralizations
+
+
+    Parameters
+    --------------
+    mesh : trimesh.Trimesh
+      Surface mesh of input geometry
+    file_type
+      Location to save output, in .msh (gmsh) or .bdf (Nastran) format
+    max_element : float or None
+      Maximum length of an element in the volume mesh
+    mesher_id : int
+      3D unstructured algorithms:
+      1: Delaunay, 3: Initial mesh only, 4: Frontal, 7: MMG3D, 9: R-tree, 10: HXT
+
+    Returns
+    ------------
+    data : None or bytes
+      MSH data, only returned if file_name is None
+
+    """
+    warnings.warn(_warning, category=DeprecationWarning, stacklevel=2)
+
+    # do import here to avoid very occasional segfaults
+    import gmsh
+
+    if tuple(int(i) for i in gmsh.__version__.split(".")) < _min_gmsh:
+        raise ImportError(
+            f"`gmsh.__version__ < {_min_gmsh}`: run `pip install --upgrade gmsh`"
+        )
+
+    # checks mesher selection
+    if mesher_id not in [1, 3, 4, 7, 9, 10]:
+        raise ValueError("unavailable mesher selected!")
+    else:
+        mesher_id = int(mesher_id)
+
+    # set max element length to a best guess if not specified
+    if max_element is None:
+        max_element = np.sqrt(np.mean(mesh.area_faces))
+
+    if file_name is not None:
+        file_type = file_name.split(".")[-1].lower()
+    elif file_type is not None:
+        file_type = file_type.lower().strip(".")
+    else:
+        file_type = "msh"
+
+    if file_type not in ["bdf", "msh", "inp", "diff", "mesh"]:
+        raise ValueError(
+            "Only Nastran (`bdf`), Gmsh (`msh`), Abaqus (`inp`), "
+            + "Diffpack (`diff`) and Inria Medit (`mesh`) formats "
+            + "are available!"
+        )
+
+    # use a temporary directory for input and output
+    with tempfile.TemporaryDirectory() as D:
+        mesh_file = os.path.join(D, "input.stl")
+        mesh.export(mesh_file)
+
+        # starts Gmsh Python API script
+        gmsh.initialize()
+        gmsh.option.setNumber("General.Terminal", 1)
+        gmsh.model.add("Nastran_stl")
+
+        gmsh.merge(mesh_file)
+        dimtag = gmsh.model.getEntities()[0]
+        dim = dimtag[0]
+        tag = dimtag[1]
+
+        surf_loop = gmsh.model.geo.addSurfaceLoop([tag])
+        gmsh.model.geo.addVolume([surf_loop])
+        gmsh.model.geo.synchronize()
+
+        # We can then generate a 3D mesh...
+        gmsh.option.setNumber("Mesh.Algorithm3D", mesher_id)
+        gmsh.option.setNumber("Mesh.CharacteristicLengthMax", max_element)
+        gmsh.model.mesh.generate(3)
+
+        dimtag2 = gmsh.model.getEntities()[1]
+        dim2 = dimtag2[0]
+        tag2 = dimtag2[1]
+        p2 = gmsh.model.addPhysicalGroup(dim2, [tag2])
+        gmsh.model.setPhysicalName(dim, p2, "Nastran_bdf")
+
+        out_path = os.path.join(D, f"temp.{file_type}")
+        gmsh.write(out_path)
+        with open(out_path, "rb") as f:
+            data = f.read()
+
+    # close up shop
+    gmsh.finalize()
+
+    # write the data
+    if file_name is not None:
+        with open(os.path.abspath(os.path.expanduser(file_name)), "wb") as f:
+            f.write(data)
+
+    return data


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b5950335..216bd7fa 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -14,7 +14,7 @@ jobs:
     - name: Set up Python ${{ matrix.python-version }}
       uses: actions/setup-python@v5
       with:
-        python-version: "3.13"
+        python-version: "3.12"
     - name: Install
       run: pip install ruff
     - name: Run Ruff
@@ -25,7 +25,7 @@ jobs:
     runs-on: ${{ matrix.os }}
     strategy:
       matrix:
-        python-version: ["3.8", "3.13"]
+        python-version: ["3.8", "3.12"]
         os: [ubuntu-latest, windows-latest, macos-13]
     steps:
     - uses: actions/checkout@v4
@@ -61,10 +61,10 @@ jobs:
       with:
         path: ~/.trimesh-cache
         key: trimesh-cache
-    - name: Set up Python 3.13
+    - name: Set up Python 3.11
       uses: actions/setup-python@v5
       with:
-         python-version: "3.13"
+         python-version: "3.11"
     - name: Install Trimesh
       run: pip install .[easy,test]
     - name: Run Corpus Check
diff --git a/tests/test_dae.py b/tests/test_dae.py
index 7da5ec60..e8f9b28a 100644
--- a/tests/test_dae.py
+++ b/tests/test_dae.py
@@ -88,6 +88,14 @@ class DAETest(g.unittest.TestCase):
         # this will compare everything in `material._data`
         assert hash(m.visual.material) == hash(r.visual.material)
 
+    def test_invalid_shininess(self):
+        s = g.get_mesh("cube_invalid_shininess.dae")
+        assert len(s.geometry) == 1
+        m = next(iter(s.geometry.values()))
+
+        # test fallback to default roughness
+        assert m.visual.material.roughnessFactor == 1.0
+
 
 if __name__ == "__main__":
     g.trimesh.util.attach_to_log()