Repository: mikedh/trimesh
Pull request #2417 (2025-06-11 19:11:27)

Release: Docstrings + IMC
- fix minimum Python version in readme
- release #2416
- release #2415 


Patch:
diff --git a/README.md b/README.md
index 0ef50014..2fd131fd 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
 [![Github Actions](https://github.com/mikedh/trimesh/workflows/Release%20Trimesh/badge.svg)](https://github.com/mikedh/trimesh/actions) [![codecov](https://codecov.io/gh/mikedh/trimesh/branch/main/graph/badge.svg?token=4PVRQXyl2h)](https://codecov.io/gh/mikedh/trimesh)  [![Docker Image Version (latest by date)](https://img.shields.io/docker/v/trimesh/trimesh?label=docker&sort=semver)](https://hub.docker.com/r/trimesh/trimesh/tags) [![PyPI version](https://badge.fury.io/py/trimesh.svg)](https://badge.fury.io/py/trimesh)
 
 
-Trimesh is a pure Python 3.7+ library for loading and using [triangular meshes](https://en.wikipedia.org/wiki/Triangle_mesh) with an emphasis on watertight surfaces. The goal of the library is to provide a full featured and well tested Trimesh object which allows for easy manipulation and analysis, in the style of the Polygon object in the [Shapely library](https://github.com/Toblerity/Shapely).
+Trimesh is a pure Python 3.8+ library for loading and using [triangular meshes](https://en.wikipedia.org/wiki/Triangle_mesh) with an emphasis on watertight surfaces. The goal of the library is to provide a full featured and well tested Trimesh object which allows for easy manipulation and analysis, in the style of the Polygon object in the [Shapely library](https://github.com/Toblerity/Shapely).
 
 The API is mostly stable, but this should not be relied on and is not guaranteed: install a specific version if you plan on deploying something using trimesh.
 
diff --git a/pyproject.toml b/pyproject.toml
index 944bb120..f125e04d 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.11"
+version = "4.6.12"
 authors = [{name = "Michael Dawson-Haggerty", email = "mikedh@kerfed.com"}]
 license = {file = "LICENSE.md"}
 description = "Import, export, process, analyze and view triangular meshes."
diff --git a/trimesh/base.py b/trimesh/base.py
index b1be6f0d..0beb4ada 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -1523,7 +1523,11 @@ class Trimesh(Geometry3D):
         edges_length = np.linalg.norm(
             np.subtract(*self.vertices[self.face_adjacency_edges.T]), axis=1
         )
-        return (self.face_adjacency_angles * edges_length).sum() * 0.5
+        # assign signs based on convex adjacency of face pairs
+        signs = np.array([-1.0, 1.0])[self.face_adjacency_convex.astype(np.int64)]
+        # adjust face adjacency angles with signs to reflect orientation
+        angles = self.face_adjacency_angles * signs
+        return (angles * edges_length).sum() * 0.5
 
     @cache_decorator
     def vertex_adjacency_graph(self) -> Graph:
diff --git a/trimesh/creation.py b/trimesh/creation.py
index 9f6a61ec..49dd8d4a 100644
--- a/trimesh/creation.py
+++ b/trimesh/creation.py
@@ -1021,9 +1021,9 @@ def cone(
     Parameters
     ----------
     radius : float
-      The radius of the cylinder
+      The radius of the cone at the widest part.
     height : float
-      The height of the cylinder
+      The height of the cone.
     sections : int or None
       How many pie wedges per revolution
     transform : (4, 4) float or None
diff --git a/trimesh/path/entities.py b/trimesh/path/entities.py
index 6a25437a..2f27d795 100644
--- a/trimesh/path/entities.py
+++ b/trimesh/path/entities.py
@@ -78,7 +78,7 @@ class Entity(ABC):
         """
         self.metadata["layer"] = value
 
-    def to_dict(self):
+    def to_dict(self) -> dict:
         """
         Returns a dictionary with all of the information
         about the entity.
@@ -566,6 +566,25 @@ class Line(Entity):
         else:
             return b"Line" + self.points[::-1].tobytes()
 
+    def to_dict(self) -> dict:
+        """
+        Returns a dictionary with all of the information
+        about the Line. `closed` is not additional information
+        for a Line like it is for Arc where the value determines
+        if it is a partial or complete circle. Rather it is a check
+        which indicates the first and last points are identical,
+        and thus should not be included in the export
+
+        Returns
+        -----------
+        as_dict
+          Has keys 'type', 'points'
+        """
+        return {
+            "type": self.__class__.__name__,
+            "points": self.points.tolist(),
+        }
+
 
 class Arc(Entity):
     @property
@@ -789,7 +808,7 @@ class BSpline(Curve):
         else:
             return b"BSpline" + self.knots[::-1].tobytes() + self.points[::-1].tobytes()
 
-    def to_dict(self):
+    def to_dict(self) -> dict:
         """
         Returns a dictionary with all of the information
         about the entity.
diff --git a/trimesh/path/exchange/misc.py b/trimesh/path/exchange/misc.py
index d4bb3dd0..923da8be 100644
--- a/trimesh/path/exchange/misc.py
+++ b/trimesh/path/exchange/misc.py
@@ -29,9 +29,12 @@ def dict_to_path(as_dict):
     entities = [None] * len(as_dict["entities"])
     # run constructor for dict kwargs
     for entity_index, entity in enumerate(as_dict["entities"]):
-        entities[entity_index] = loaders[entity["type"]](
-            points=entity["points"], closed=entity["closed"]
-        )
+        if entity["type"] == "Line":
+            entities[entity_index] = loaders[entity["type"]](points=entity["points"])
+        else:
+            entities[entity_index] = loaders[entity["type"]](
+                points=entity["points"], closed=entity["closed"]
+            )
     result["entities"] = entities
 
     return result
diff --git a/trimesh/path/path.py b/trimesh/path/path.py
index f918c694..155b1071 100644
--- a/trimesh/path/path.py
+++ b/trimesh/path/path.py
@@ -731,9 +731,8 @@ class Path(parent.Geometry):
         """
         return export_path(self, file_type=file_type, file_obj=file_obj, **kwargs)
 
-    def to_dict(self):
-        export_dict = self.export(file_type="dict")
-        return export_dict
+    def to_dict(self) -> dict:
+        return self.export(file_type="dict")
 
     def copy(self):
         """


Test patch:
diff --git a/tests/test_integralmeancurvature.py b/tests/test_integralmeancurvature.py
index 4375cde5..5f6a2677 100644
--- a/tests/test_integralmeancurvature.py
+++ b/tests/test_integralmeancurvature.py
@@ -4,41 +4,64 @@ except BaseException:
     import generic as g
 
 
-class IntegralMeanCurvatureTest(g.unittest.TestCase):
-    def test_IMCsphere(self):
-        # how close do we need to be - relative tolerance
-        tol = 1e-3
-        for radius in [0.1, 1.0, 3.1459, 29.20]:
-            m = g.trimesh.creation.icosphere(subdivisions=4, radius=radius)
-            IMC = m.integral_mean_curvature
-            ref = 4 * g.np.pi * radius
-            assert g.np.isclose(IMC, ref, rtol=tol)
-
-    def test_IMCcapsule(self):
-        # how close do we need to be - relative tolerance
-        tol = 1e-3
-        radius = 1.2
-        for aspect_ratio in [0.0, 0.5, 1.0, 4.0, 100]:
-            L = aspect_ratio * radius
-            m = g.trimesh.creation.capsule(height=L, radius=radius, count=[64, 64])
-            IMC = m.integral_mean_curvature
-            ref = g.np.pi * (L + 4 * radius)
-            assert g.np.isclose(IMC, ref, rtol=tol)
-
-    def test_IMCbox(self):
-        # how close do we need to be - relative tolerance
-        tol = 1e-3
-        n_tests = 4
-        extents = 1 - g.random((n_tests, 3))
-        for extent in extents:
-            m = g.trimesh.creation.box(extents=extent)
-            IMC = m.integral_mean_curvature
-            # only the right angle (=pi/2) edges contribute,
-            # edges that lie on the faces of the box do not
-            ref = 4 * extent.sum() * g.np.pi / 2 * 0.5
-            assert g.np.isclose(IMC, ref, rtol=tol)
+def test_imc_sphere():
+    # how close do we need to be - relative tolerance
+    tol = 1e-3
+    for radius in [0.1, 1.0, 3.1459, 29.20]:
+        m = g.trimesh.creation.icosphere(subdivisions=4, radius=radius)
+        IMC = m.integral_mean_curvature
+        ref = 4 * g.np.pi * radius
+        assert g.np.isclose(IMC, ref, rtol=tol)
+
+
+def test_imc_capsule():
+    # how close do we need to be - relative tolerance
+    tol = 1e-3
+    radius = 1.2
+    for aspect_ratio in [0.0, 0.5, 1.0, 4.0, 100]:
+        L = aspect_ratio * radius
+        m = g.trimesh.creation.capsule(height=L, radius=radius, count=[64, 64])
+        IMC = m.integral_mean_curvature
+        ref = g.np.pi * (L + 4 * radius)
+        assert g.np.isclose(IMC, ref, rtol=tol)
+
+
+def test_imc_box():
+    # how close do we need to be - relative tolerance
+    tol = 1e-3
+    n_tests = 4
+    extents = 1 - g.random((n_tests, 3))
+    for extent in extents:
+        m = g.trimesh.creation.box(extents=extent)
+        IMC = m.integral_mean_curvature
+        # only the right angle (=pi/2) edges contribute,
+        # edges that lie on the faces of the box do not
+        ref = 4 * extent.sum() * g.np.pi / 2 * 0.5
+        assert g.np.isclose(IMC, ref, rtol=tol)
+
+
+def test_imc_torus():
+    # how close do we need to be - relative tolerance
+    tol = 1e-2
+    r = 1.1
+    for aspect_ratio in [1.1, 2.1, 4.0, 10.0]:
+        R = r * aspect_ratio
+        m = g.trimesh.creation.torus(
+            major_radius=R, minor_radius=r, major_sections=100, minor_sections=100
+        )
+        IMC = m.integral_mean_curvature
+        ref = 2 * (g.np.pi**2) * R
+        assert g.np.isclose(IMC, ref, rtol=tol)
 
 
 if __name__ == "__main__":
     g.trimesh.util.attach_to_log()
-    g.unittest.main()
+
+    from pyinstrument import Profiler
+
+    with Profiler() as P:
+        test_imc_sphere()
+        test_imc_box()
+        test_imc_torus()
+        test_imc_capsule()
+    P.print()
diff --git a/tests/test_paths.py b/tests/test_paths.py
index b3622cf5..4a84fc77 100644
--- a/tests/test_paths.py
+++ b/tests/test_paths.py
@@ -410,8 +410,44 @@ def test_svg_arc_snap():
     assert p.extrude(10.0).is_volume
 
 
+def test_path_dict_export():
+    x, y = 2, 3
+
+    # define incorrectly
+    as_dict = {
+        "entities": [
+            {"type": "Line", "points": [0, 1, 2, 3, 0], "closed": False},
+        ],
+        "vertices": [
+            [-x / 2, y / 2],
+            [x / 2, y / 2],
+            [x / 2, -y / 2],
+            [-x / 2, -y / 2],
+        ],
+    }
+
+    # try loading with the incorrect `closed` value for the line
+    path = g.trimesh.path.exchange.load.load_path(
+        g.trimesh.path.exchange.misc.dict_to_path(as_dict)
+    )
+
+    # export to a dict again
+    exp = path.export(file_type="dict")
+    assert len(exp["entities"]) == 1
+    assert "closed" not in exp["entities"][0]
+
+    # do a roundtrip
+    rt = g.trimesh.path.exchange.load.load_path(
+        g.trimesh.path.exchange.misc.dict_to_path(exp)
+    )
+
+    # should have the same area
+    assert g.np.isclose(rt.area, path.area)
+
+
 if __name__ == "__main__":
     g.trimesh.util.attach_to_log()
     # g.unittest.main()
 
-    test_path_to_gltf_with_arc()
+    # test_path_to_gltf_with_arc()
+    test_path_dict_export()