Repository: mikedh/trimesh

Issue:
OBJ export doesn't have newline on last line.
Currently this breaks Prusa Slicer.
I reported it to them too, but I think it best if both sides fix it.
(PS should be more forgiving, Trimesh really should end the line properly.)



Discussion:

Patch:
diff --git a/Dockerfile b/Dockerfile
index ceed0e0d1..fe5705727 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.11-slim-bookworm AS base
+FROM python:3.12-slim-bookworm AS base
 LABEL maintainer="mikedh@kerfed.com"
 
 # Install helper script to PATH.
@@ -63,7 +63,7 @@ COPY --chown=499 pyproject.toml .
 COPY --chown=499 ./.git ./.git/
 
 USER root
-RUN trimesh-setup --install=test,gltf_validator,llvmpipe,binvox
+RUN trimesh-setup --install=test,build,gltf_validator,llvmpipe,binvox
 USER user
 
 # install things like pytest
diff --git a/pyproject.toml b/pyproject.toml
index 09ec756e3..0c5e9e5ac 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ requires = ["setuptools >= 61.0", "wheel"]
 [project]
 name = "trimesh"
 requires-python = ">=3.7"
-version = "4.1.0"
+version = "4.1.1"
 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/exchange/obj.py b/trimesh/exchange/obj.py
index 569610fbf..a5bc65351 100644
--- a/trimesh/exchange/obj.py
+++ b/trimesh/exchange/obj.py
@@ -957,6 +957,10 @@ def export_obj(
     if header is not None:
         # add a created-with header to the top of the file
         objects.appendleft(f"# {header}")
+
+    # add a trailing newline
+    objects.append("\n")
+
     # combine elements into a single string
     text = "\n".join(objects)
 
diff --git a/trimesh/exchange/off.py b/trimesh/exchange/off.py
index 03bcc10e0..c856dee80 100644
--- a/trimesh/exchange/off.py
+++ b/trimesh/exchange/off.py
@@ -2,11 +2,11 @@
 
 import numpy as np
 
-from .. import util
 from ..geometry import triangulate_quads
+from ..util import array_to_string, comment_strip, decode_text
 
 
-def load_off(file_obj, **kwargs):
+def load_off(file_obj, **kwargs) -> dict:
     """
     Load an OFF file into the kwargs for a Trimesh constructor.
 
@@ -23,7 +23,7 @@ def load_off(file_obj, **kwargs):
     text = file_obj.read()
     # will magically survive weird encoding sometimes
     # comment strip will handle all cases of commenting
-    text = util.comment_strip(util.decode_text(text)).strip()
+    text = comment_strip(decode_text(text)).strip()
 
     # split the first key
     _, header, raw = re.split("(COFF|OFF)", text, maxsplit=1)
@@ -58,7 +58,7 @@ def load_off(file_obj, **kwargs):
     return kwargs
 
 
-def export_off(mesh, digits=10):
+def export_off(mesh, digits=10) -> str:
     """
     Export a mesh as an OFF file, a simple text format
 
@@ -80,14 +80,17 @@ def export_off(mesh, digits=10):
     faces_stacked = np.column_stack((np.ones(len(mesh.faces)) * 3, mesh.faces)).astype(
         np.int64
     )
-    export = "OFF\n"
     # the header is vertex count, face count, another number
-    export += str(len(mesh.vertices)) + " " + str(len(mesh.faces)) + " 0\n"
-    export += (
-        util.array_to_string(mesh.vertices, col_delim=" ", row_delim="\n", digits=digits)
-        + "\n"
+    export = "\n".join(
+        [
+            "OFF",
+            str(len(mesh.vertices)) + " " + str(len(mesh.faces)) + " 0",
+            array_to_string(mesh.vertices, col_delim=" ", row_delim="\n", digits=digits),
+            array_to_string(faces_stacked, col_delim=" ", row_delim="\n"),
+            "",
+        ]
     )
-    export += util.array_to_string(faces_stacked, col_delim=" ", row_delim="\n")
+
     return export
 
 
diff --git a/trimesh/exchange/ply.py b/trimesh/exchange/ply.py
index 1ffbb5a7e..389d2f10b 100644
--- a/trimesh/exchange/ply.py
+++ b/trimesh/exchange/ply.py
@@ -342,27 +342,35 @@ def export_ply(
             _add_attributes_to_data_array(faces, mesh.face_attributes)
 
     header.append(templates["outro"])
-    export = Template("".join(header)).substitute(header_params).encode("utf-8")
+    export = [Template("".join(header)).substitute(header_params).encode("utf-8")]
 
     if encoding == "binary_little_endian":
         if hasattr(mesh, "vertices"):
-            export += vertex.tobytes()
+            export.append(vertex.tobytes())
         if hasattr(mesh, "faces"):
-            export += faces.tobytes()
+            export.append(faces.tobytes())
     elif encoding == "ascii":
-        export_data = util.structured_array_to_string(
-            vertex, col_delim=" ", row_delim="\n"
+        export.append(
+            util.structured_array_to_string(vertex, col_delim=" ", row_delim="\n").encode(
+                "utf-8"
+            ),
         )
+
         if hasattr(mesh, "faces"):
-            export_data += "\n"
-            export_data += util.structured_array_to_string(
-                faces, col_delim=" ", row_delim="\n"
+            export.extend(
+                [
+                    b"\n",
+                    util.structured_array_to_string(
+                        faces, col_delim=" ", row_delim="\n"
+                    ).encode("utf-8"),
+                ]
             )
-        export += export_data.encode("utf-8")
+        export.append(b"\n")
+
     else:
         raise ValueError("encoding must be ascii or binary!")
 
-    return export
+    return b"".join(export)
 
 
 def _parse_header(file_obj):
diff --git a/trimesh/exchange/stl.py b/trimesh/exchange/stl.py
index 57e763cbb..d5e5e47a7 100644
--- a/trimesh/exchange/stl.py
+++ b/trimesh/exchange/stl.py
@@ -231,6 +231,7 @@ def load_stl_ascii(file_obj):
             "vertices": vertices.reshape((-1, 3)),
             "face_normals": face_normals,
             "faces": faces,
+            "metadata": {"name": name},
         }
 
     if len(kwargs) == 1:
@@ -239,17 +240,19 @@ def load_stl_ascii(file_obj):
     return {"geometry": kwargs}
 
 
-def export_stl(mesh):
+def export_stl(mesh) -> bytes:
     """
     Convert a Trimesh object into a binary STL file.
 
     Parameters
     ---------
-    mesh: Trimesh object
+    mesh
+      Trimesh object to export.
 
     Returns
     ---------
-    export: bytes, representing mesh in binary STL form
+    export
+      Represents mesh in binary STL form
     """
     header = np.zeros(1, dtype=_stl_dtype_header)
     if hasattr(mesh, "faces"):
@@ -265,7 +268,7 @@ def export_stl(mesh):
     return export
 
 
-def export_stl_ascii(mesh):
+def export_stl_ascii(mesh) -> str:
     """
     Convert a Trimesh object into an ASCII STL file.
 
@@ -275,8 +278,8 @@ def export_stl_ascii(mesh):
 
     Returns
     ---------
-    export : str
-        Mesh represented as an ASCII STL file
+    export
+      Mesh represented as an ASCII STL file
     """
 
     # move all the data that's going into the STL file into one array
@@ -285,17 +288,28 @@ def export_stl_ascii(mesh):
     blob[:, 1:, :] = mesh.triangles
 
     # create a lengthy format string for the data section of the file
-    format_string = "facet normal {} {} {}\nouter loop\n"
-    format_string += "vertex {} {} {}\n" * 3
-    format_string += "endloop\nendfacet\n"
-    format_string *= len(mesh.faces)
+    formatter = (
+        "\n".join(
+            [
+                "facet normal {} {} {}",
+                "outer loop",
+                "vertex {} {} {}\nvertex {} {} {}\nvertex {} {} {}",
+                "endloop",
+                "endfacet",
+                "",
+            ]
+        )
+    ) * len(mesh.faces)
 
-    # concatenate the header, data, and footer
-    export = "solid \n"
-    export += format_string.format(*blob.reshape(-1))
-    export += "endsolid"
+    # try applying the name from metadata if it exists
+    name = mesh.metadata.get("name", "")
+    if not isinstance(name, str):
+        name = ""
+    if len(name) > 80 or "\n" in name:
+        name = ""
 
-    return export
+    # concatenate the header, data, and footer, and a new line
+    return "\n".join(["solid {name}", formatter.format(*blob.reshape(-1)), "endsolid\n"])
 
 
 _stl_loaders = {"stl": load_stl, "stl_ascii": load_stl}


Test patch:
diff --git a/tests/generic.py b/tests/generic.py
index ae1e815c1..a9466c32f 100644
--- a/tests/generic.py
+++ b/tests/generic.py
@@ -351,6 +351,7 @@ def check(item):
                 loaded = trimesh.load(os.path.join(dir_models, file_name))
             except BaseException as E:
                 if raise_error:
+                    log.error(f'failed to load {file_name}')
                     raise E
                 continue
 
diff --git a/tests/test_export.py b/tests/test_export.py
index 22fc44953..a8707834d 100644
--- a/tests/test_export.py
+++ b/tests/test_export.py
@@ -47,6 +47,9 @@ def test_export(self):
 
                 g.log.info("Export/import testing on %s", mesh.metadata["file_name"])
 
+                if isinstance(export, str):
+                    assert export.endswith("\n"), f"{file_type} doesn't end with newline"
+
                 # if export is string or bytes wrap as pseudo file object
                 if isinstance(export, str) or isinstance(export, bytes):
                     file_obj = g.io_wrap(export)
@@ -180,6 +183,11 @@ def test_export(self):
                     assert len(r.vertices) == len(mesh.vertices)
                     assert len(r.faces) == len(mesh.faces)
 
+                    if option.get("encoding", None) == "ascii":
+                        with open(temp.name) as f:
+                            exported = f.read()
+                        assert exported.endswith("\n")
+
                     # manual cleanup
                     g.os.remove(temp.name)
                     g.os.remove(temp_off.name)