Repository: mikedh/trimesh
Pull request #2371 (2025-03-09 22:22:08)

Release: Box Normals
- Face normals were being recomputed unnecessarily for `Box` primitives.
- release #2367 
- update docker images to Python 3.13 as Debian trixie removed 3.12.
  


Patch:
diff --git a/Dockerfile b/Dockerfile
index 7de3bf38..34becbfb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,8 +5,8 @@ LABEL maintainer="mikedh@kerfed.com"
 # Create a non-root user with `uid=499`.
 RUN useradd -m -u 499 -s /bin/bash user && \
     apt-get update && \
-    apt-get install --no-install-recommends -qq -y python3.12-venv && \
-    apt-get clean -y 
+    apt-get install --no-install-recommends -qq -y python3.13-venv && \
+    apt-get clean -y && rm -rf /var/lib/apt/lists/*
 
 USER user
 
@@ -17,7 +17,7 @@ WORKDIR /home/user
 # but if you use Debian methods like `update-alternatives`
 # it won't provide a `pip` which works easily and it isn't
 # easy to know how system packages interact with pip packages
-RUN python3.12 -m venv venv
+RUN python3.13 -m venv venv
 
 # So scripts installed from pip are in $PATH
 ENV PATH="/home/user/venv/bin:$PATH"
@@ -30,6 +30,13 @@ COPY --chmod=755 docker/trimesh-setup /home/user/venv/bin
 ## install things that need building
 FROM base AS build
 
+USER root
+# `xatlas` currently needs to compile on 3.13 from the sdist
+RUN apt-get update && \
+    apt-get install --no-install-recommends -y python3.13-dev build-essential g++ && \
+    apt-get clean -y && rm -rf /var/lib/apt/lists/*
+USER user
+
 # copy in essential files
 COPY --chown=499 trimesh/ /home/user/trimesh
 COPY --chown=499 pyproject.toml /home/user/
@@ -37,10 +44,6 @@ COPY --chown=499 pyproject.toml /home/user/
 # install trimesh into the venv
 RUN pip install /home/user[easy]
 
-# install FCL from a hopefully temporary fork
-# as the original `python-fcl` currently has broken wheels on PyPi
-RUN pip install fclx
-
 ####################################
 ### Build output image most things should run on
 FROM base AS output
@@ -62,7 +65,7 @@ COPY --chown=499 pyproject.toml .
 COPY --chown=499 ./.git ./.git/
 
 USER root
-RUN trimesh-setup --install=test,gmsh,gltf_validator,llvmpipe,binvox,blender
+RUN trimesh-setup --install=test,gmsh,gltf_validator,llvmpipe,binvox,blender,build
 USER user
 
 RUN blender --version
diff --git a/docker/trimesh-setup b/docker/trimesh-setup
index f66977d7..c47280f8 100755
--- a/docker/trimesh-setup
+++ b/docker/trimesh-setup
@@ -25,6 +25,7 @@ config_json = """
   "apt": {
     "build": [
       "build-essential",
+      "python3.13-dev",
       "g++",
       "make",
       "git"
diff --git a/pyproject.toml b/pyproject.toml
index 30429c23..070e5c53 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.4"
+version = "4.6.5"
 authors = [{name = "Michael Dawson-Haggerty", email = "mikedh@kerfed.com"}]
 license = {file = "LICENSE.md"}
 description = "Import, export, process, analyze and view triangular meshes."
@@ -94,7 +94,7 @@ recommend = [
     "psutil",
     "scikit-image",
     "fast-simplification",
-    # "python-fcl", # do collision checks # TODO : broken on numpy 2
+    "python-fcl", # do collision checks
     "openctm; platform_machine=='x86_64'", # load `CTM` compressed models
     "cascadio", # load `STEP` files
 ]
diff --git a/trimesh/base.py b/trimesh/base.py
index bc096ca0..d2cb1bd9 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -79,6 +79,12 @@ except BaseException as E:
     Path2D = ExceptionWrapper(E)
     Path3D = ExceptionWrapper(E)
 
+# save immutable identity matrices for checks
+_IDENTITY3 = np.eye(3, dtype=np.float64)
+_IDENTITY3.flags.writeable = False
+_IDENTITY4 = np.eye(4, dtype=np.float64)
+_IDENTITY4.flags.writeable = False
+
 
 class Trimesh(Geometry3D):
     def __init__(
@@ -2446,7 +2452,7 @@ class Trimesh(Geometry3D):
 
         # exit early if we've been passed an identity matrix
         # np.allclose is surprisingly slow so do this test
-        elif util.allclose(matrix, np.eye(4), 1e-8):
+        elif util.allclose(matrix, _IDENTITY4, 1e-8):
             return self
 
         # new vertex positions
@@ -2454,7 +2460,7 @@ class Trimesh(Geometry3D):
 
         # check to see if the matrix has rotation
         # rather than just translation
-        has_rotation = not util.allclose(matrix[:3, :3], np.eye(3), atol=1e-6)
+        has_rotation = not util.allclose(matrix[:3, :3], _IDENTITY3, atol=1e-6)
 
         # transform overridden center of mass
         if "center_mass" in self._data:
diff --git a/trimesh/exchange/gltf.py b/trimesh/exchange/gltf.py
index 1c361ca9..ea30d9ce 100644
--- a/trimesh/exchange/gltf.py
+++ b/trimesh/exchange/gltf.py
@@ -1180,14 +1180,23 @@ def _append_path(path, name, tree, buffer_items):
         else:
             data = attrib
 
-        data = util.stack_lines(data).reshape((-1,))
+        if not all(util.is_instance_named(e, "Line") for e in path.entities):
+            log.warning(
+                f"Vertex attributes are only supported for Line entities, skipping `{key}`"
+            )
+            continue
+
+        data_discretized = np.array(
+            [util.stack_lines(e.discrete(data)) for e in path.entities]
+        )
+        stacked_data = data_discretized.reshape((-1,))
 
         # store custom vertex attributes
         current["primitives"][0]["attributes"][key] = _data_append(
             acc=tree["accessors"],
             buff=buffer_items,
-            blob=_build_accessor(data),
-            data=data,
+            blob=_build_accessor(stacked_data),
+            data=stacked_data,
         )
 
     tree["meshes"].append(current)
diff --git a/trimesh/primitives.py b/trimesh/primitives.py
index e05c3aac..515d2419 100644
--- a/trimesh/primitives.py
+++ b/trimesh/primitives.py
@@ -77,11 +77,19 @@ class Primitive(Trimesh):
 
     @property
     def face_normals(self):
+        # if the mesh hasn't been created yet do that
+        # before checking to see if the mesh creation
+        # already populated the face normals
+        if "vertices" not in self._cache:
+            self._create_mesh()
+
         # we need to avoid the logic in the superclass that
         # is specific to the data model prioritizing faces
         stored = self._cache["face_normals"]
         if util.is_shape(stored, (-1, 3)):
             return stored
+
+        # if the creation did not populate normals we have to do it
         # just calculate if not stored
         unit, valid = triangles.normals(self.triangles)
         normals = np.zeros((len(valid), 3))


Test patch:
diff --git a/tests/generic.py b/tests/generic.py
index 8b1080fb..21f2cacc 100644
--- a/tests/generic.py
+++ b/tests/generic.py
@@ -26,6 +26,7 @@ import contextlib
 import subprocess
 import collections
 import numpy as np
+import pytest
 
 import trimesh
 
diff --git a/tests/test_paths.py b/tests/test_paths.py
index fb806919..92a3796f 100644
--- a/tests/test_paths.py
+++ b/tests/test_paths.py
@@ -131,6 +131,62 @@ def test_path():
     assert len(loaded_path.vertex_attributes["_test"]) == 3
 
 
+def test_path_to_gltf_with_line():
+    path = g.trimesh.path.Path3D(
+        entities=[
+            g.trimesh.path.entities.Line(points=[0, 1, 2]),
+            g.trimesh.path.entities.Line(points=[3, 4, 5]),
+        ],
+        vertices=g.np.array(
+            [
+                [0, 0, 0],
+                [0, 1, 0],
+                [1, 1, 0],
+                [0, 0, 1],
+                [0, 1, 1],
+                [1, 1, 2],
+            ]
+        ),
+    )
+    path.vertex_attributes = {"_test": g.np.array([0, 0, 0, 1, 1, 1], dtype=g.np.float32)}
+
+    tree, _ = g.trimesh.exchange.gltf._create_gltf_structure(g.trimesh.Scene([path]))
+    # should have included the attribute by name
+    assert set(tree["meshes"][0]["primitives"][0]["attributes"].keys()) == {
+        "POSITION",
+        "_test",
+    }
+
+    assert len(tree["accessors"]) == 2
+    acc = tree["accessors"]
+    assert acc[0]["count"] == acc[1]["count"]
+
+
+def test_path_to_gltf_with_arc():
+    path = g.trimesh.path.Path3D(
+        entities=[
+            g.trimesh.path.entities.Line(points=[0, 1, 2]),
+            g.trimesh.path.entities.Arc(points=[3, 4, 5]),
+        ],
+        vertices=g.np.array(
+            [
+                [0, 0, 0],
+                [0, 1, 0],
+                [1, 1, 0],
+                [0, 0, 1],
+                [0, 1, 1],
+                [1, 1, 2],
+            ]
+        ),
+    )
+    path.vertex_attributes = {"_test": g.np.array([0, 0, 0, 1, 1, 1], dtype=g.np.float32)}
+
+    tree, _ = g.trimesh.exchange.gltf._create_gltf_structure(g.trimesh.Scene([path]))
+
+    # should have skipped the attribute rather than exporting a broken one
+    assert set(tree["meshes"][0]["primitives"][0]["attributes"].keys()) == {"POSITION"}
+
+
 def test_poly():
     p = g.get_mesh("2D/LM2.dxf")
     assert p.is_closed
@@ -357,4 +413,6 @@ def test_svg_arc_snap():
 
 if __name__ == "__main__":
     g.trimesh.util.attach_to_log()
-    g.unittest.main()
+    # g.unittest.main()
+
+    test_path_to_gltf_with_arc()
diff --git a/tests/test_scenegraph.py b/tests/test_scenegraph.py
index b08a4c31..cddc305c 100644
--- a/tests/test_scenegraph.py
+++ b/tests/test_scenegraph.py
@@ -7,7 +7,7 @@ from trimesh.scene.transforms import EnforcedForest
 
 
 def random_chr():
-    return chr(ord("a") + int(round(g.random() * 25)))
+    return chr(ord("a") + round(g.random() * 25))
 
 
 def test_forest():