Repository: mikedh/trimesh

Issue:
UVs are missing after converting OBJ with quad faces to GLB.
UVs are missing after converting OBJ with quad faces to GLB.

And I found that there are some TODOs in this file, trimesh->exchange->obj.py

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 == 6:

     ....


Unfortunately, the value of coloum is 8 for my obj file


Segfault on many mesh copies (worse if cached)
I think this is a new issue, though it looks somewhat similar to https://github.com/mikedh/trimesh/issues/39 .

I need a few thousand different (slightly rotated, shifted, scaled) meshes for my application, and I was seeing Segfaults. They actually show up quicker when I explicitly cache the meshes.

With this MWE
```python
import trimesh
from functools import lru_cache
from copy import deepcopy
from icecream import ic


mesh = trimesh.creation.icosphere(subdivisions=4)

@lru_cache(maxsize=2048)
def get_mesh_copy(mesh: trimesh.Trimesh, index: int) -> trimesh.Trimesh:
    return deepcopy(mesh)

for i in range(0, 10000):
    if i % 100 == 0:
        ic(i)
    mesh_copy = get_mesh_copy(mesh, i)
    is_inside = mesh_copy.contains([[0.5, 0.5, 0.5]])
```

I get an output of 
```
ic| i: 0
ic| i: 100
ic| i: 200
ic| i: 300
ic| i: 400
ic| i: 500
ic| i: 600
ic| i: 700
ic| i: 800
ic| i: 900
ic| i: 1000

Segmentation fault (core dumped)
```
which apparently originates from here
```
Thread 1 "python3" received signal SIGSEGV, Segmentation fault.
__pyx_f_7embreex_17mesh_construction_12TriangleMesh__build_from_indices (__pyx_v_self=0x7fff78426940, __pyx_v_scene=0x7fff7956e2c0, __pyx_v_tri_vertices=0x7fff784291d0, 
    __pyx_v_tri_indices=0x7fff784290b0) at embreex/mesh_construction.cpp:5573
```

Is there something that I overlooked? Am I supposed to only use a certain number of meshes? Should I try explicit garbage collection? Is this actually an issue for `embreex`? Is there a generally smarter way to do this? :)


Discussion:

Patch:
diff --git a/models/quadknot.obj b/models/quadknot.obj
index 874493a96..1f54b14cf 100644
--- a/models/quadknot.obj
+++ b/models/quadknot.obj
@@ -2895,7 +2895,8 @@ v -5.277687 23.362453 -81.432793 0.752941 0.752941 0.752941
 
 
 usemtl material_0
-vt 0.000000 0.000000
+# make the UV nonzero for our unit test ;)
+vt 0.100000 0.000000
 vt 0.000000 0.000000
 f 13/1/13 14/1/14 2/1/2 1/2/1
 f 14/1/14 15/1/15 3/1/3 2/2/2
diff --git a/pyproject.toml b/pyproject.toml
index df944f91e..944bb1208 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ requires = ["setuptools >= 61.0", "wheel"]
 [project]
 name = "trimesh"
 requires-python = ">=3.8"
-version = "4.6.10"
+version = "4.6.11"
 authors = [{name = "Michael Dawson-Haggerty", email = "mikedh@kerfed.com"}]
 license = {file = "LICENSE.md"}
 description = "Import, export, process, analyze and view triangular meshes."
@@ -89,13 +89,11 @@ easy = [
 recommend = [
     "sympy",
     "meshio",
-    # "xatlas",
     "pyglet<2",
     "psutil",
     "scikit-image",
     "fast-simplification",
     "python-fcl", # do collision checks
-    "openctm; platform_machine=='x86_64'", # load `CTM` compressed models
     "cascadio", # load `STEP` files
 ]
 
@@ -115,6 +113,7 @@ test_more = [
     "coveralls",
     "pyright",
     "ezdxf",
+    "xatlas",
     "pytest-beartype; python_version>='3.10'",
     "matplotlib",
     "pymeshlab",
@@ -123,8 +122,9 @@ test_more = [
     "marimo",
 ]
 
-# interfaces.gmsh will be dropped Jan 2025
-deprecated = ["gmsh==4.12.2"]
+# interfaces.gmsh will be dropped sometime after January 2025
+# openctm will be fully removed sometime after June 2026
+deprecated = ["gmsh==4.12.2","openctm"]
 
 # requires pip >= 21.2
 # https://hynek.me/articles/python-recursive-optional-dependencies/
diff --git a/trimesh/exchange/obj.py b/trimesh/exchange/obj.py
index b70f1f0ae..81bb87dba 100644
--- a/trimesh/exchange/obj.py
+++ b/trimesh/exchange/obj.py
@@ -450,6 +450,19 @@ def _parse_faces_vectorized(array, columns, sample_line):
         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/ray/ray_pyembree.py b/trimesh/ray/ray_pyembree.py
index 848196627..31f028c93 100644
--- a/trimesh/ray/ray_pyembree.py
+++ b/trimesh/ray/ray_pyembree.py
@@ -324,6 +324,12 @@ def __setstate__(self, state):
         # Add cache back since it doesn't exist in the pickle
         self._cache = caching.Cache(id_function=self.mesh.__hash__)
 
+    def __deepcopy__(self, *args):
+        return self.__copy__()
+
+    def __copy__(self, *args):
+        return RayMeshIntersector(geometry=self.mesh, scale_to_box=self._scale_to_box)
+
 
 class _EmbreeWrap:
     """
diff --git a/trimesh/visual/color.py b/trimesh/visual/color.py
index e3f92460d..dba73a38f 100644
--- a/trimesh/visual/color.py
+++ b/trimesh/visual/color.py
@@ -30,13 +30,13 @@
 from ..constants import tol
 from ..grouping import unique_rows
 from ..typed import (
+    Any,
     ArrayLike,
     DTypeLike,
     Integer,
     Iterable,
     NDArray,
     Optional,
-    Tuple,
     Union,
 )
 from .base import Visuals
@@ -561,9 +561,7 @@ def __hash__(self):
         return self._colors.__hash__()
 
 
-def to_rgba(
-    colors: Union[ArrayLike, None, Tuple], dtype: DTypeLike = np.uint8
-) -> NDArray:
+def to_rgba(colors: Any, dtype: DTypeLike = np.uint8) -> NDArray:
     """
     Convert a single or multiple RGB colors to RGBA colors.
 
diff --git a/trimesh/voxel/ops.py b/trimesh/voxel/ops.py
index eb910b7da..003b16408 100644
--- a/trimesh/voxel/ops.py
+++ b/trimesh/voxel/ops.py
@@ -236,7 +236,7 @@ def points_to_marching_cubes(points, pitch=1.0):
     return mesh
 
 
-def multibox(centers, pitch=1.0, colors=None):
+def multibox(centers, pitch=1.0, colors=None, remove_internal_faces=False):
     """
     Return a Trimesh object with a box at every center.
 
@@ -250,6 +250,8 @@ def multibox(centers, pitch=1.0, colors=None):
       The edge length of a voxel
     colors : (3,) or (4,) or (n,3) or (n, 4) float
       Color of boxes
+    remove_internal_faces : bool
+      If True, removes internal faces shared between adjacent boxes
 
     Returns
     ---------
@@ -272,9 +274,31 @@ def multibox(centers, pitch=1.0, colors=None):
     v += np.tile(b.vertices, (len(centers), 1))
 
     f = np.tile(b.faces, (len(centers), 1))
-    f += np.tile(np.arange(len(centers)) * len(b.vertices), (len(b.faces), 1)).T.reshape(
-        (-1, 1)
-    )
+    f += np.repeat(np.arange(len(centers)) * len(b.vertices), len(b.faces))[:, None]
+
+    if remove_internal_faces:
+        # Get 12 unit normals (1 per triangle face) indicating face direction
+        base_normals = np.round(b.face_normals).astype(int)  # (12, 3)
+        # Expand those directions across all voxel boxes so as to check neighbor presence
+        face_normals = np.tile(base_normals, (len(centers), 1))  # (len(centers) * 12, 3)
+
+        # Maps each face to the voxel box it came from
+        face_voxel_idx = np.repeat(
+            np.arange(len(centers)), len(b.faces)
+        )  # (len(centers) * 12, )
+        # Converts voxel centers to discrete grid coordinates
+        voxel_coords = np.round(centers / pitch).astype(int)  # (len(centers), 3)
+        # Creates a fast lookup structure for checking voxel neighbors
+        voxel_set = set(map(tuple, voxel_coords))
+
+        # Gets the grid coordinate of the voxel that owns each face
+        voxel_face_coords = voxel_coords[face_voxel_idx]
+        # Computes the adjacent voxel coordinate in the face direction
+        neighbor_coords = voxel_face_coords + face_normals
+        # Keeps only faces whose neighboring voxel does not exist
+        keep_mask = np.array([tuple(c) not in voxel_set for c in neighbor_coords])
+    else:
+        keep_mask = np.ones(len(f), dtype=bool)
 
     face_colors = None
     if colors is not None:
@@ -282,9 +306,9 @@ def multibox(centers, pitch=1.0, colors=None):
         if colors.ndim == 1:
             colors = colors[None].repeat(len(centers), axis=0)
         if colors.ndim == 2 and len(colors) == len(centers):
-            face_colors = colors.repeat(12, axis=0)
+            face_colors = colors.repeat(12, axis=0)[keep_mask]
 
-    mesh = Trimesh(vertices=v, faces=f, face_colors=face_colors)
+    mesh = Trimesh(vertices=v, faces=f[keep_mask], face_colors=face_colors)
 
     return mesh
 


Test patch:
diff --git a/tests/test_convex.py b/tests/test_convex.py
index 4d7ec6c3a..1dce0bf5e 100644
--- a/tests/test_convex.py
+++ b/tests/test_convex.py
@@ -9,7 +9,7 @@ def test_convex(self):
         # store (true is_convex, mesh) tuples
         meshes = [
             (False, g.get_mesh("featuretype.STL")),
-            (False, g.get_mesh("quadknot.obj")),
+            (False, g.get_mesh("quadknot.obj", merge_tex=True)),
             (True, g.get_mesh("unit_cube.STL")),
             (False, g.get_mesh("1002_tray_bottom.STL")),
             (True, g.trimesh.creation.icosphere()),
diff --git a/tests/test_multibox.py b/tests/test_multibox.py
new file mode 100644
index 000000000..50b5224c7
--- /dev/null
+++ b/tests/test_multibox.py
@@ -0,0 +1,47 @@
+try:
+    from . import generic as g
+except BaseException:
+    import generic as g
+
+
+class MultiboxTest(g.unittest.TestCase):
+    def test_multibox_internal_face_removal(self):
+        np = g.np
+
+        # Create a small 2 * 2 * 2 binary voxelgrid
+        voxelgrid = np.ones((2, 2, 2), dtype=bool)
+        indices = np.argwhere(voxelgrid)
+        points = g.trimesh.voxel.ops.indices_to_points(indices)
+
+        # With internal faces (remove_internal_faces=False)
+        mesh_w_internal_faces = g.trimesh.voxel.ops.multibox(
+            points, pitch=1.0, remove_internal_faces=False
+        )
+
+        # Without internal faces (remove_internal_faces=True)
+        mesh_wo_internal_faces = g.trimesh.voxel.ops.multibox(
+            points, pitch=1.0, remove_internal_faces=True
+        )
+        self.assertLess(
+            len(mesh_wo_internal_faces.faces), len(mesh_w_internal_faces.faces)
+        )
+
+        # Check bounding boxes are equal
+        g.np.testing.assert_allclose(
+            mesh_w_internal_faces.bounds, mesh_wo_internal_faces.bounds
+        )
+
+        # A 2 * 2 * 2 voxelgrid constitutes 8 voxels
+        # Each voxel has 6 sides, with 2 triangular faces per side, totaling 6 * 2 = 12 triangular faces
+        # 8 voxels * 12 triangular faces = 96 triangluar faces (when all the internal faces kept)
+        self.assertEqual(len(mesh_w_internal_faces.faces), 96)
+
+        # A 2 * 2 * 2 block has 6 exposed sides (i.e., top, bottom, front, back, left, right)
+        # Each exposed side has 4 sub-sides corresponding to the 4 voxels, with 2 triangular faces per sub-side, totaling 4 * 2 = 8 triangular faces
+        # 6 exposed sides * 8 triangular face = 48 triangular faces (when all the internal faces removed)
+        self.assertEqual(len(mesh_wo_internal_faces.faces), 48)
+
+
+if __name__ == "__main__":
+    g.trimesh.util.attach_to_log()
+    g.unittest.main()
diff --git a/tests/test_obj.py b/tests/test_obj.py
index fe80b0519..924751832 100644
--- a/tests/test_obj.py
+++ b/tests/test_obj.py
@@ -68,15 +68,15 @@ def test_obj_groups():
 
 def test_obj_negative_indices():
     # a wavefront file with negative indices
-    mesh = g.get_mesh("negative_indices.obj")
+    mesh = g.get_mesh("negative_indices.obj", merge_tex=True, merge_norm=True)
 
     # make sure some data got loaded
-    assert g.trimesh.util.is_shape(mesh.faces, (12, 3))
-    assert g.trimesh.util.is_shape(mesh.vertices, (8, 3))
+    assert mesh.faces.shape == (12, 3)
+    assert mesh.vertices.shape == (8, 3)
 
 
 def test_obj_quad():
-    mesh = g.get_mesh("quadknot.obj")
+    mesh = g.get_mesh("quadknot.obj", merge_tex=True)
     # make sure some data got loaded
     assert g.trimesh.util.is_shape(mesh.faces, (-1, 3))
     assert g.trimesh.util.is_shape(mesh.vertices, (-1, 3))
@@ -552,5 +552,14 @@ def test_material_name():
     g.check_fuze(roundtrip)
 
 
+def test_obj_quad_uv():
+    # check that the quads with UV's get translated
+    m = g.get_mesh("quadknot.obj")
+
+    assert m.visual.uv.shape[0] == len(m.vertices)
+    assert g.np.isclose(m.visual.uv.max(), 0.1)
+
+
 if __name__ == "__main__":
-    test_material_name()
+    # test_material_name()
+    test_obj_quad_uv()