Repository: mikedh/trimesh
Pull request #2356 (2025-02-12 15:10:12)

Add vertex attributes in path
This PR adds support for `vertex_attributes` in paths, to match the functionality of `Trimesh` when importing/exporting custom attributes for lines from/to glTF.


Patch:
diff --git a/pyproject.toml b/pyproject.toml
index 8ca45128..ab272133 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.3"
+version = "4.6.2"
 authors = [{name = "Michael Dawson-Haggerty", email = "mikedh@kerfed.com"}]
 license = {file = "LICENSE.md"}
 description = "Import, export, process, analyze and view triangular meshes."
@@ -74,6 +74,7 @@ easy = [
     "networkx",
     "svg.path",
     "pycollada",
+    "setuptools",
     "shapely",
     "xxhash",
     "rtree",
diff --git a/trimesh/exchange/gltf.py b/trimesh/exchange/gltf.py
index cedf12ec..1c361ca9 100644
--- a/trimesh/exchange/gltf.py
+++ b/trimesh/exchange/gltf.py
@@ -1165,6 +1165,31 @@ def _append_path(path, name, tree, buffer_items):
         # add color to attributes
         current["primitives"][0]["attributes"]["COLOR_0"] = acc_color
 
+    # for each attribute with a leading underscore, assign them to path
+    # vertex_attributes
+    for key, attrib in path.vertex_attributes.items():
+        # Application specific attributes must be
+        # prefixed with an underscore
+        if not key.startswith("_"):
+            key = "_" + key
+
+        # GLTF has no floating point type larger than 32 bits so clip
+        # any float64 or larger to float32
+        if attrib.dtype.kind == "f" and attrib.dtype.itemsize > 4:
+            data = attrib.astype(np.float32)
+        else:
+            data = attrib
+
+        data = util.stack_lines(data).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,
+        )
+
     tree["meshes"].append(current)
 
 
@@ -1525,6 +1550,13 @@ def _read_buffers(
 
                     kwargs["vertices"] = access[attr["POSITION"]]
                     kwargs["entities"] = [Line(points=np.arange(len(kwargs["vertices"])))]
+
+                    # custom attributes starting with a `_`
+                    custom = {
+                        a: access[attr[a]] for a in attr.keys() if a.startswith("_")
+                    }
+                    if len(custom) > 0:
+                        kwargs["vertex_attributes"] = custom
                 elif mode == _GL_POINTS:
                     kwargs["vertices"] = access[attr["POSITION"]]
                     visuals = None
diff --git a/trimesh/path/path.py b/trimesh/path/path.py
index a1e3714b..ac3370ed 100644
--- a/trimesh/path/path.py
+++ b/trimesh/path/path.py
@@ -78,6 +78,7 @@ class Path(parent.Geometry):
         metadata: Optional[Dict] = None,
         process: bool = True,
         colors=None,
+        vertex_attributes: Optional[Dict] = None,
         **kwargs,
     ):
         """
@@ -105,6 +106,10 @@ class Path(parent.Geometry):
         if isinstance(metadata, dict):
             self.metadata.update(metadata)
 
+        self.vertex_attributes = {}
+        if vertex_attributes is not None:
+            self.vertex_attributes.update(vertex_attributes)
+
         # cache will dump whenever self.crc changes
         self._cache = caching.Cache(id_function=self.__hash__)
 
@@ -523,6 +528,9 @@ class Path(parent.Geometry):
 
         unique, inverse = grouping.unique_rows(self.vertices, digits=digits)
         self.vertices = self.vertices[unique]
+        self.vertex_attributes = {
+            key: np.array(value)[unique] for key, value in self.vertex_attributes.items()
+        }
 
         entities_ok = np.ones(len(self.entities), dtype=bool)
 
diff --git a/trimesh/visual/color.py b/trimesh/visual/color.py
index 7d4baa50..85ebd0ba 100644
--- a/trimesh/visual/color.py
+++ b/trimesh/visual/color.py
@@ -70,6 +70,13 @@ class ColorVisuals(Visuals):
         self._data = caching.DataStore()
         self._cache = caching.Cache(id_function=self._data.__hash__)
 
+        self.defaults = {
+            "material_diffuse": np.array([102, 102, 102, 255], dtype=np.uint8),
+            "material_ambient": np.array([64, 64, 64, 255], dtype=np.uint8),
+            "material_specular": np.array([197, 197, 197, 255], dtype=np.uint8),
+            "material_shine": 77.0,
+        }
+
         try:
             if face_colors is not None:
                 self.face_colors = face_colors
@@ -319,7 +326,7 @@ class ColorVisuals(Visuals):
             if self.kind is None:
                 # no colors are defined, so create a (count, 4) tiled
                 # copy of the default color
-                colors = np.tile(DEFAULT_MAT["material_diffuse"], (count, 1))
+                colors = np.tile(self.defaults["material_diffuse"], (count, 1))
             elif self.kind == "vertex" and name == "face":
                 colors = vertex_to_face_color(
                     vertex_colors=self.vertex_colors, faces=self.mesh.faces
@@ -1099,9 +1106,3 @@ def color_to_uv(vertex_colors: ArrayLike):
 
 # set an arbitrary grey as the default color
 DEFAULT_COLOR = np.array([102, 102, 102, 255], dtype=np.uint8)
-DEFAULT_MAT = {
-    "material_diffuse": np.array([102, 102, 102, 255], dtype=np.uint8),
-    "material_ambient": np.array([64, 64, 64, 255], dtype=np.uint8),
-    "material_specular": np.array([197, 197, 197, 255], dtype=np.uint8),
-    "material_shine": 77.0,
-}


Test patch:
diff --git a/tests/generic.py b/tests/generic.py
index 09e5a062..8b1080fb 100644
--- a/tests/generic.py
+++ b/tests/generic.py
@@ -6,6 +6,7 @@ might need, to reduce the amount of boilerplate.
 
 import os
 import sys
+import io
 import json
 import copy
 import time
@@ -567,6 +568,47 @@ def roundtrip(exported, file_type, **kwargs):
     )
 
 
+def to_glb_bytes(geom: trimesh.Geometry) -> bytes:
+    """
+    Returns the content of the binary glTF file for the given geometry.
+
+    Parameters
+    ----------
+    geom : trimesh.Geometry
+      Geometry to export
+
+    Returns
+    -------
+    data : bytes
+      Content of the binary glTF file
+    """
+    data = trimesh.exchange.gltf.export_glb(geom)
+
+    return data
+
+
+def from_glb_bytes(data: bytes) -> trimesh.Scene:
+    """
+    Returns the scene from the given binary glTF file content.
+
+    Parameters
+    ----------
+    data : bytes
+      Content of the binary glTF file
+
+    Returns
+    -------
+    scene : trimesh.Scene
+      Scene
+    """
+    bytes_stream = io.BytesIO(data)
+    kwargs = trimesh.exchange.gltf.load_glb(bytes_stream)
+
+    scene = trimesh.exchange.load._load_kwargs(kwargs)
+
+    return scene
+
+
 # all the JSON files with truth data
 data = _load_data()
 
diff --git a/tests/test_paths.py b/tests/test_paths.py
index bffab2ea..0eb4d8de 100644
--- a/tests/test_paths.py
+++ b/tests/test_paths.py
@@ -111,6 +111,32 @@ 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()
+
+    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_poly():
     p = g.get_mesh("2D/LM2.dxf")
     assert p.is_closed