Repository: mikedh/trimesh
Pull request #2283 (2024-09-09 04:04:26)

fix for #2269 with new test case for incomplete creation.revolve()
Added a cap: bool parameter to creation.revolve(...) and implement additional logic to add end cap faces to an incomplete revolution so that the resultant mesh is a valid volume.


Patch:
diff --git a/trimesh/creation.py b/trimesh/creation.py
index c01381a6..33ab9658 100644
--- a/trimesh/creation.py
+++ b/trimesh/creation.py
@@ -41,6 +41,7 @@ _engines = [
 def revolve(
     linestring: ArrayLike,
     angle: Optional[Number] = None,
+    cap: bool = False,
     sections: Optional[Integer] = None,
     transform: Optional[ArrayLike] = None,
     **kwargs,
@@ -125,10 +126,6 @@ def revolve(
         # chop off duplicate vertices
         vertices = vertices[:-per]
 
-    if transform is not None:
-        # apply transform to vertices
-        vertices = tf.transform_points(vertices, transform)
-
     # how many slices of the pie
     slices = len(theta) - 1
 
@@ -160,18 +157,53 @@ def revolve(
     # if 'process' not in kwargs:
     #    kwargs['process'] = False
 
+    # Handle capping before applying any transformation
+    if not closed and cap:
+        # Use the triangulated linestring as the base cap faces (cap_0), assuming no new vertices 
+        # are added, indices defining triangles of cap_0 should be reusable for cap_angle
+        cap_0_vertices, cap_0_faces = triangulate_polygon(Polygon(linestring))
+
+        if tol.strict:
+            # make sure we didn't screw up triangulation
+            _, idx = np.unique(cap_0_vertices, return_index=True)
+            cap_0_uvtxs = cap_0_vertices[np.sort(idx)]
+            _, idx = np.unique(linestring, return_index=True)
+            line_uvtxs = linestring[np.sort(idx)]
+            assert np.allclose(cap_0_uvtxs, line_uvtxs)
+
+        # Use the last set of vertices as the top cap contour (cap_angle)
+        offset = len(vertices) - per
+        cap_angle_faces = cap_0_faces + offset
+        flipped_cap_angle_faces = np.fliplr(cap_angle_faces) # reverse the winding
+
+        # Append cap faces to the face array
+        faces = np.vstack([faces, cap_0_faces, flipped_cap_angle_faces])
+
+    if transform is not None:
+        # apply transform to vertices
+        vertices = tf.transform_points(vertices, transform)
+
     # create the mesh from our vertices and faces
     mesh = Trimesh(vertices=vertices, faces=faces, **kwargs)
-
-    # strict checks run only in unit tests
+    
+    # HACK: minor repairs if needed
+    if not closed and cap and not mesh.is_volume:
+      mesh.update_faces(mesh.nondegenerate_faces())
+      mesh.update_faces(mesh.unique_faces())
+      mesh.remove_infinite_values()
+      mesh.remove_unreferenced_vertices()
+      mesh.fix_normals()
+
+    # strict checks run only in unit tests and when cap is True
     if tol.strict and (
         np.allclose(radius[[0, -1]], 0.0) or np.allclose(linestring[0], linestring[-1])
     ):
-        # if revolved curve starts and ends with zero radius
-        # it should really be a valid volume, unless the sign
-        # reversed on the input linestring
-        assert closed
-        assert mesh.is_volume
+        if not closed and cap:
+          # if revolved curve starts and ends with zero radius
+          # it should really be a valid volume, unless the sign
+          # reversed on the input linestring
+          assert mesh.is_volume
+        
         assert mesh.body_count == 1
 
     return mesh
@@ -616,7 +648,7 @@ def triangulate_polygon(
         return result["vertices"], result["triangles"]
 
     log.warning(
-        "try running `pip install mapbox-earcut manifold3d`"
+        "try running `pip install manifold3d`"
         + "or `triangle`, `mapbox_earcut`, then explicitly pass:\n"
         + '`triangulate_polygon(*args, engine="triangle")`\n'
         + "to use the non-FSF-approved-license triangle engine"
diff --git a/trimesh/path/exchange/export.py b/trimesh/path/exchange/export.py
index 54ca0134..9a5db560 100644
--- a/trimesh/path/exchange/export.py
+++ b/trimesh/path/exchange/export.py
@@ -1,7 +1,7 @@
 import os
 
 from ... import util
-from . import dxf, ply, svg_io
+from . import dxf, svg_io
 
 
 def export_path(path, file_type=None, file_obj=None, **kwargs):
@@ -73,9 +73,4 @@ def _write_export(export, file_obj=None):
     return export
 
 
-_path_exporters = {
-    "dxf": dxf.export_dxf,
-    "svg": svg_io.export_svg,
-    "ply": ply.export_ply,
-    "dict": export_dict,
-}
+_path_exporters = {"dxf": dxf.export_dxf, "svg": svg_io.export_svg, "dict": export_dict}
diff --git a/trimesh/path/exchange/load.py b/trimesh/path/exchange/load.py
index f2dc1016..f3d012b2 100644
--- a/trimesh/path/exchange/load.py
+++ b/trimesh/path/exchange/load.py
@@ -4,7 +4,6 @@ from ... import util
 from ..path import Path
 from . import misc
 from .dxf import _dxf_loaders
-from .ply import load_ply
 from .svg_io import svg_to_path
 
 
@@ -43,22 +42,14 @@ def load_path(file_obj, file_type=None, **kwargs):
         return file_obj
     elif util.is_file(file_obj):
         # for open file file_objects use loaders
-        if file_type == "ply":
-            # we cannot register this exporter to path_loaders since this is already reserved by TriMesh in ply format in trimesh.load()
-            kwargs.update(load_ply(file_obj, file_type=file_type))
-        else:
-            kwargs.update(path_loaders[file_type](file_obj, file_type=file_type))
+        kwargs.update(path_loaders[file_type](file_obj, file_type=file_type))
     elif util.is_string(file_obj):
         # strings passed are evaluated as file file_objects
         with open(file_obj, "rb") as f:
             # get the file type from the extension
             file_type = os.path.splitext(file_obj)[-1][1:].lower()
-            if file_type == "ply":
-                # we cannot register this exporter to path_loaders since this is already reserved by TriMesh in ply format in trimesh.load()
-                kwargs.update(load_ply(f, file_type=file_type))
-            else:
-                # call the loader
-                kwargs.update(path_loaders[file_type](f, file_type=file_type))
+            # call the loader
+            kwargs.update(path_loaders[file_type](f, file_type=file_type))
     elif util.is_instance_named(file_obj, ["Polygon", "MultiPolygon"]):
         # convert from shapely polygons to Path2D
         kwargs.update(misc.polygon_to_path(file_obj))
diff --git a/trimesh/path/exchange/ply.py b/trimesh/path/exchange/ply.py
deleted file mode 100644
index d9431ebc..00000000
--- a/trimesh/path/exchange/ply.py
+++ /dev/null
@@ -1,151 +0,0 @@
-from string import Template
-
-import numpy as np
-
-from ... import resources, util
-from ...exchange.ply import _elements_to_kwargs, _parse_header, _ply_ascii, _ply_binary
-from ...typed import Dict, Optional
-from .misc import edges_to_path
-
-
-def export_ply(
-    path,
-    encoding: Optional[str] = "binary",
-) -> bytes:
-    """
-    Export a path in the PLY format.
-
-    Parameters
-    -----------
-    path: path to export
-    encoding: PLY encoding: 'ascii' or 'binary_little_endian'
-
-    Returns
-    -----------
-    export: bytes of result
-    """
-    # evaluate input args
-    # allow a shortcut for binary
-    if encoding == "binary":
-        encoding = "binary_little_endian"
-    elif encoding not in ["binary_little_endian", "ascii"]:
-        raise ValueError("encoding must be binary or ascii")
-
-    # custom numpy dtypes for exporting
-    dtype_edge = [("index", "<i4", (2))]
-    dtype_vertex = [("vertex", "<f4", (3))]
-
-    # get template strings in dict
-    templates = resources.get_json("templates/ply.json")
-    templates["edge"] = (
-        "element edge $edge_count\nproperty int vertex1\nproperty int vertex2\n"
-    )
-    # start collecting elements into a string for the header
-    header = [templates["intro"]]
-
-    # check if scene has geometry
-    if hasattr(path, "vertices") and hasattr(path, "entities"):
-
-        if len(path.vertices) and path.vertices.shape[-1] != 3:
-            raise ValueError("only Path3D export is supported for ply")
-
-        entities = []
-        vertices = []
-        for e in path.entities:
-            entity_points = len(vertices)
-            discretized_path = e.discrete(path.vertices).tolist()
-            for pp in range(len(discretized_path) - 1):
-                entities.append((entity_points + pp, entity_points+pp + 1))
-            vertices.extend(discretized_path)
-
-        # create and populate the custom dtype for vertices
-        num_vertices = len(vertices)
-        vertex = np.zeros(num_vertices, dtype=dtype_vertex)
-        if num_vertices:
-            header.append(templates["vertex"])
-            vertex["vertex"] = np.asarray(vertices, dtype=np.float32)
-
-        # put mesh edge data into custom dtype to export
-        num_edges = len(entities)
-        edges = np.zeros(num_edges, dtype=dtype_edge)
-        if num_edges:
-            header.append(templates["edge"])
-            edges["index"] = np.asarray(entities, dtype=np.int32)
-    else:
-        num_vertices = 0
-        num_edges = 0
-
-    header_params = {"vertex_count": num_vertices, "edge_count": num_edges, "encoding": encoding}
-
-    header.append(templates["outro"])
-    export = [Template("".join(header)).substitute(header_params).encode("utf-8")]
-
-    if encoding == "binary_little_endian":
-        if hasattr(path, "vertices"):
-            export.append(vertex.tobytes())
-        if hasattr(path, "entities"):
-            export.append(edges.tobytes())
-    elif encoding == "ascii":
-        if hasattr(path, "vertices"):
-            export.append(
-            util.structured_array_to_string(vertex, col_delim=" ", row_delim="\n").encode(
-                "utf-8"
-            ),
-        )
-
-        if hasattr(path, "entities"):
-            export.extend(
-                [
-                    b"\n",
-                    util.structured_array_to_string(
-                        edges, col_delim=" ", row_delim="\n"
-                    ).encode("utf-8"),
-                ]
-            )
-        export.append(b"\n")
-    else:
-        raise ValueError("encoding must be ascii or binary!")
-
-    return b"".join(export)
-
-
-def load_ply(file_obj, **kwargs) -> Dict:
-    """
-    Load a PLY file to a dictionary containing vertices and entities
-
-    Parameters
-    -----------
-    file_obj: file or file-like object (has object.read method)
-
-    Returns
-    -----------
-    result: keys are entities, vertices and metadata
-    """
-
-    # OrderedDict which is populated from the header
-    elements, is_ascii, image_name = _parse_header(file_obj)
-
-    # functions will fill in elements from file_obj
-    if is_ascii:
-        _ply_ascii(elements, file_obj)
-    else:
-        _ply_binary(elements, file_obj)
-
-    # translate loaded PLY elements to kwargs
-    kwargs = _elements_to_kwargs(image=None, elements=elements, fix_texture=False)
-
-    try:
-        v1 = kwargs["metadata"]["_ply_raw"]["edge"]["data"]["vertex1"]
-        v2 = kwargs["metadata"]["_ply_raw"]["edge"]["data"]["vertex2"]
-        result = edges_to_path(np.column_stack((v1, v2)), kwargs["vertices"])
-    except KeyError:
-        # special case for empty files
-        result = kwargs
-        result.update({"entities": [], "geometry": None})
-        if "vertices" not in kwargs:
-            result.update({"vertices": np.asarray([], dtype=np.float32)})
-
-    # return result as kwargs for Path constructor
-    result.update({"metadata": {}})
-
-    return result
diff --git a/trimesh/triangles.py b/trimesh/triangles.py
index 8920a625..91128d10 100644
--- a/trimesh/triangles.py
+++ b/trimesh/triangles.py
@@ -83,10 +83,8 @@ def normals(triangles=None, crosses=None):
     valid : (n,) bool
       Was the face nonzero area or not
     """
-    if triangles is not None:
-        triangles = np.asanyarray(triangles, dtype=np.float64)
-        if triangles.shape[-1] == 2:
-            return np.tile([0.0, 0.0, 1.0], (triangles.shape[0], 1))
+    if triangles is not None and triangles.shape[-1] == 2:
+        return np.tile([0.0, 0.0, 1.0], (triangles.shape[0], 1))
     if crosses is None:
         crosses = cross(triangles)
     # unitize the cross product vectors


Test patch:
diff --git a/tests/test_creation.py b/tests/test_creation.py
index 6df20824..1282e47e 100644
--- a/tests/test_creation.py
+++ b/tests/test_creation.py
@@ -312,6 +312,22 @@ class CreationTest(g.unittest.TestCase):
         assert all(s.volume > 0 for s in split)
 
 
+    def test_revolve(self):
+        # create a cross section and revolve it to form some volumes
+        cross_section = [[0, 0], [10, 0], [10, 10], [0, 10]]
+
+        # high sections needed so volume is close to theoretical value for perfect revoulution
+        mesh360 = g.trimesh.creation.revolve(cross_section, 2 * g.np.pi, sections=360)
+        mesh360_volume = g.np.pi * 10**2 * 10
+        assert g.np.isclose(mesh360.volume, mesh360_volume, rtol=.1)
+        assert mesh360.is_volume, "mesh360 should be a valid volume"
+
+        mesh180 = g.trimesh.creation.revolve(cross_section, g.np.pi, sections=180, cap=True)
+        assert g.np.isclose(mesh180.volume, mesh360.volume/2, rtol=.1), \
+            "mesh180 should be half of mesh360 volume"
+        assert mesh180.is_volume, "mesh180 should be a valid volume"
+
+
 def check_triangulation(v, f, true_area):
     assert g.trimesh.util.is_shape(v, (-1, 2))
     assert v.dtype.kind == "f"
@@ -340,7 +356,6 @@ def test_torus():
     assert g.np.allclose(m.extents, extents)
     assert g.np.allclose(m.bounds, [-extents / 2.0, extents / 2.0])
 
-
 if __name__ == "__main__":
     g.trimesh.util.attach_to_log()
     g.unittest.main()
diff --git a/tests/test_export.py b/tests/test_export.py
index b94b221c..380761d6 100644
--- a/tests/test_export.py
+++ b/tests/test_export.py
@@ -248,72 +248,6 @@ class ExportTest(g.unittest.TestCase):
         # the scene should be identical after export-> import cycle
         assert g.np.allclose(loaded.extents / source.extents, 1.0)
 
-    def test_ply_path_empty(self):
-        """
-        Test empty path export does not fail
-        """
-        path3D = g.trimesh.path.Path3D()
-
-        # export to ply
-        ply = path3D.export(file_type="ply")
-        assert len(ply) > 0
-
-        loaded = g.trimesh.load_path(g.trimesh.util.wrap_as_stream(ply), file_type="ply")
-        assert g.np.allclose(loaded.entities, path3D.entities)
-        assert g.np.allclose(loaded.vertices, path3D.vertices)
-
-    def test_ply_path_line(self):
-        """
-        Should be able to load a path and export simple line as a PLY
-        """
-        path3D = g.trimesh.load_path([(0, 0, 0), (1, 0, 0), (1, 1, 0), (1, 1, 1)])
-        assert isinstance(path3D, g.trimesh.path.Path3D)
-
-        # export to ply
-        ply = path3D.export(file_type="ply")
-        assert len(ply) > 0
-
-        loaded = g.trimesh.load_path(g.trimesh.util.wrap_as_stream(ply), file_type="ply")
-        assert g.np.allclose(loaded.vertices, path3D.vertices)
-
-    def test_ply_path_multi(self):
-        """
-        Should be able to load a path and export multiple entities as a PLY
-        """
-        path3D = g.trimesh.path.Path3D(
-            [
-                g.trimesh.path.entities.Line([0, 1, 2]),
-                g.trimesh.path.entities.Line([2, 0, 1])
-            ],
-            [(0., 0., 0.), (1., 0., 0.), (1., 1., 0.)]
-        )
-
-        # export to ply
-        ply = path3D.export(file_type="ply")
-        assert len(ply) > 0
-
-        loaded = g.trimesh.load_path(g.trimesh.util.wrap_as_stream(ply), file_type="ply")
-        assert len(loaded.entities) == len(path3D.entities)
-        assert len(loaded.vertices) > 0
-
-    def test_ply_path_bezier(self):
-        """
-        Should be able to load a path and export complex curve as a PLY
-        """
-        path3D = g.trimesh.path.Path3D(
-            [g.trimesh.path.entities.Bezier([0, 1, 2])],
-            [(0., 0., 0.), (1., 0., 0.), (1., 1., 0.)]
-        )
-
-        # export to ply
-        ply = path3D.export(file_type="ply")
-        assert len(ply) > 0
-
-        loaded = g.trimesh.load_path(g.trimesh.util.wrap_as_stream(ply), file_type="ply")
-        # note: we cannot recover the exact entities and vertices from the export since it is discretized for ply files
-        assert len(loaded.entities) > 0
-        assert len(loaded.vertices) > 0
-
     def test_gltf_path(self):
         """
         Check to make sure GLTF exports of Path2D and Path3D