Repository: mikedh/trimesh
Pull request #2367 (2025-03-03 13:01:40)

Fix complex path export
This PR will fix exporting of complex paths (i.e., having multiple `Line` entities).

This will still fail for other entities than `Line` that need proper discretization, but there seems to be no trivial way to solve this problem for curves.


Patch:
diff --git a/Dockerfile b/Dockerfile
index 32070ad8..7de3bf38 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.13-venv && \
-    apt-get clean -y && rm -rf /var/lib/apt/lists/*
+    apt-get install --no-install-recommends -qq -y python3.12-venv && \
+    apt-get clean -y 
 
 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.13 -m venv venv
+RUN python3.12 -m venv venv
 
 # So scripts installed from pip are in $PATH
 ENV PATH="/home/user/venv/bin:$PATH"
@@ -30,13 +30,6 @@ 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/
@@ -44,6 +37,10 @@ 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
diff --git a/pyproject.toml b/pyproject.toml
index 070e5c53..30429c23 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.5"
+version = "4.6.4"
 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
+    # "python-fcl", # do collision checks # TODO : broken on numpy 2
     "openctm; platform_machine=='x86_64'", # load `CTM` compressed models
     "cascadio", # load `STEP` files
 ]
diff --git a/trimesh/base.py b/trimesh/base.py
index d2cb1bd9..bc096ca0 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -79,12 +79,6 @@ 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__(
@@ -2452,7 +2446,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, _IDENTITY4, 1e-8):
+        elif util.allclose(matrix, np.eye(4), 1e-8):
             return self
 
         # new vertex positions
@@ -2460,7 +2454,7 @@ class Trimesh(Geometry3D):
 
         # check to see if the matrix has rotation
         # rather than just translation
-        has_rotation = not util.allclose(matrix[:3, :3], _IDENTITY3, atol=1e-6)
+        has_rotation = not util.allclose(matrix[:3, :3], np.eye(3), 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..20b3330a 100644
--- a/trimesh/exchange/gltf.py
+++ b/trimesh/exchange/gltf.py
@@ -16,6 +16,7 @@ import numpy as np
 from .. import rendering, resources, transformations, util, visual
 from ..caching import hash_fast
 from ..constants import log, tol
+from ..path.entities import Line
 from ..resolvers import ResolverLike, ZipResolver
 from ..scene.cameras import Camera
 from ..typed import Dict, List, NDArray, Optional, Stream
@@ -1180,14 +1181,21 @@ def _append_path(path, name, tree, buffer_items):
         else:
             data = attrib
 
-        data = util.stack_lines(data).reshape((-1,))
+        if not all(isinstance(e, Line) for e in path.entities):
+            raise ValueError("Vertex attributes are only supported for Line entities.")
+
+        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 515d2419..e05c3aac 100644
--- a/trimesh/primitives.py
+++ b/trimesh/primitives.py
@@ -77,19 +77,11 @@ 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..9ebc505b 100644
--- a/tests/test_paths.py
+++ b/tests/test_paths.py
@@ -111,24 +111,75 @@ def test_discrete():
         d.process()
 
 
-def test_path():
-    path = g.trimesh.path.Path3D(
-        entities=[g.trimesh.path.entities.Line(points=[0, 1, 2])],
-        vertices=[[0, 0, 0], [1, 0, 0], [1, 1, 0]],
-        vertex_attributes={"_test": g.np.array([1, 2, 3], dtype=g.np.float32)},
-    )
-
-    glb_data = g.to_glb_bytes(path)
-    loaded_scene = g.from_glb_bytes(glb_data)
-
-    loaded_path = loaded_scene.geometry["geometry_0"]
-    loaded_path.process()
+class TestPath(g.unittest.TestCase):
+    def test_path(self):
+        path = g.trimesh.path.Path3D(
+            entities=[g.trimesh.path.entities.Line(points=[0, 1, 2])],
+            vertices=[[0, 0, 0], [1, 0, 0], [1, 1, 0]],
+            vertex_attributes={"_test": g.np.array([1, 2, 3], dtype=g.np.float32)},
+        )
 
-    assert isinstance(loaded_path, g.trimesh.path.Path3D)
+        glb_data = g.to_glb_bytes(path)
+        loaded_scene = g.from_glb_bytes(glb_data)
+
+        loaded_path = loaded_scene.geometry["geometry_0"]
+        loaded_path.process()
+
+        assert isinstance(loaded_path, g.trimesh.path.Path3D)
+
+        assert loaded_path.vertices.shape == (3, 3)
+        assert len(loaded_path.entities) == 1
+        assert len(loaded_path.vertex_attributes["_test"]) == 3
+
+
+    def test_path_to_gltf_with_line(self):
+        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]))
+
+        assert len(tree["accessors"]) == 2
+
+        acc = tree["accessors"]
+
+        assert acc[0]["count"] == acc[1]["count"]
+
+    def test_path_to_gltf_with_arc(self):
+        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)
+        }
 
-    assert loaded_path.vertices.shape == (3, 3)
-    assert len(loaded_path.entities) == 1
-    assert len(loaded_path.vertex_attributes["_test"]) == 3
+        with g.pytest.raises(ValueError):
+            tree, _ = g.trimesh.exchange.gltf._create_gltf_structure(g.trimesh.Scene([path]))
 
 
 def test_poly():
diff --git a/tests/test_scenegraph.py b/tests/test_scenegraph.py
index cddc305c..b08a4c31 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") + round(g.random() * 25))
+    return chr(ord("a") + int(round(g.random() * 25)))
 
 
 def test_forest():