Repository: mikedh/trimesh
Pull request #2352 (2025-02-05 11:10:56)

Fix linestring to path
This PR fixes an issues with converting `shapely.Linestring` to `Path3D`.


Patch:
diff --git a/Dockerfile b/Dockerfile
index 7de3bf38..f864e423 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -37,9 +37,8 @@ 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
+# install FCL which currently has broken wheels on PyPi
+RUN pip install https://github.com/BerkeleyAutomation/python-fcl/releases/download/v0.7.0.7/python_fcl-0.7.0.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
 
 ####################################
 ### Build output image most things should run on
diff --git a/docs/content/install.md b/docs/content/install.md
index c121d543..06e028f6 100644
--- a/docs/content/install.md
+++ b/docs/content/install.md
@@ -18,6 +18,11 @@ If you\'d like most soft dependencies which should install cleanly on Mac, Windo
 pip install trimesh[easy]
 ```
 
+Or if you want the full experience, you can try the `all` extra which includes all the testing and recommended packages:
+```
+pip install trimesh[all]
+```
+
 
 ## Conda Packages
 
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 44919076..934ecbe7 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,11 +1,11 @@
-pypandoc==1.15
+pypandoc==1.14
 recommonmark==0.7.1
 jupyter==1.1.1
 
 # get sphinx version range from furo install
 furo==2024.8.6
 myst-parser==4.0.0
-pyopenssl==25.0.0
+pyopenssl==24.3.0
 autodocsumm==0.2.14
 jinja2==3.1.5
 matplotlib==3.10.0
diff --git a/pyproject.toml b/pyproject.toml
index 576f5fcc..8d5892db 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.2"
+version = "4.6.0"
 authors = [{name = "Michael Dawson-Haggerty", email = "mikedh@kerfed.com"}]
 license = {file = "LICENSE.md"}
 description = "Import, export, process, analyze and view triangular meshes."
@@ -96,7 +96,7 @@ recommend = [
     "scikit-image",
     "fast-simplification",
     # "python-fcl", # do collision checks # TODO : broken on numpy 2
-    "openctm; platform_machine=='x86_64'", # load `CTM` compressed models
+    "openctm", # load `CTM` compressed models
     "cascadio", # load `STEP` files
 ]
 
@@ -149,7 +149,6 @@ select = [
     "UP", # upgrade
     "W", # style warnings
     "YTT", # sys.version
-    "ISC001",
     "ISC002",
     "NPY201",
     "TID251"
diff --git a/trimesh/bounds.py b/trimesh/bounds.py
index 0215a8b4..64fc20d3 100644
--- a/trimesh/bounds.py
+++ b/trimesh/bounds.py
@@ -2,7 +2,6 @@ import numpy as np
 
 from . import convex, geometry, grouping, nsphere, transformations, util
 from .constants import log, now
-from .typed import ArrayLike, NDArray
 
 try:
     # scipy is a soft dependency
@@ -549,9 +548,9 @@ def corners(bounds):
     return corners
 
 
-def contains(bounds: ArrayLike, points: ArrayLike) -> NDArray[np.bool_]:
+def contains(bounds, points):
     """
-    Do an axis aligned bounding box check on an array of points.
+    Do an axis aligned bounding box check on a list of points.
 
     Parameters
     -----------
diff --git a/trimesh/exchange/load.py b/trimesh/exchange/load.py
index c0f57191..3faba2f9 100644
--- a/trimesh/exchange/load.py
+++ b/trimesh/exchange/load.py
@@ -1,6 +1,5 @@
 import json
 import os
-from copy import deepcopy
 
 import numpy as np
 
@@ -211,8 +210,7 @@ def load_scene(
             )
         elif arg.file_type in mesh_loaders:
             # use mesh loader
-            parsed = deepcopy(kwargs)
-            parsed.update(
+            loaded = _load_kwargs(
                 mesh_loaders[arg.file_type](
                     file_obj=arg.file_obj,
                     file_type=arg.file_type,
@@ -221,8 +219,6 @@ def load_scene(
                     **kwargs,
                 )
             )
-            loaded = _load_kwargs(**parsed)
-
         elif arg.file_type in compressed_loaders:
             # for archives, like ZIP files
             loaded = _load_compressed(arg.file_obj, file_type=arg.file_type, **kwargs)
diff --git a/trimesh/exchange/threemf.py b/trimesh/exchange/threemf.py
index 754121aa..9713cf2d 100644
--- a/trimesh/exchange/threemf.py
+++ b/trimesh/exchange/threemf.py
@@ -39,7 +39,7 @@ def _read_mesh(mesh):
     # the `.join` as the giant string can be fully allocated
     vs = " ".join(
         [
-            f"{i.attrib['x']} {i.attrib['y']} {i.attrib['z']}"
+            f'{i.attrib["x"]} {i.attrib["y"]} {i.attrib["z"]}'
             for i in vertices.iter("{*}vertex")
         ]
     )
@@ -49,7 +49,7 @@ def _read_mesh(mesh):
     # do the same behavior for faces but as an integer
     fs = " ".join(
         [
-            f"{i.attrib['v1']} {i.attrib['v2']} {i.attrib['v3']}"
+            f'{i.attrib["v1"]} {i.attrib["v2"]} {i.attrib["v3"]}'
             for i in faces.iter("{*}triangle")
         ]
     )
diff --git a/trimesh/path/arc.py b/trimesh/path/arc.py
index 52517080..5ac765e6 100644
--- a/trimesh/path/arc.py
+++ b/trimesh/path/arc.py
@@ -194,22 +194,15 @@ def discretize_arc(points, close=False, scale=1.0):
 
     # do an in-process check to make sure result endpoints
     # match the endpoints of the source arc
-    if not close:
-        if tol.strict:
-            arc_dist = util.row_norm(points[[0, -1]] - discrete[[0, -1]])
-            arc_ok = (arc_dist < tol.merge).all()
-            if not arc_ok:
-                log.warning(
-                    "failed to discretize arc (endpoint_distance=%s R=%s)",
-                    str(arc_dist),
-                    R,
-                )
-                log.warning("Failed arc points: %s", str(points))
-                raise ValueError("Arc endpoints diverging!")
-        # snap the discrete result to exact control points
-        discrete[[0, -1]] = points[[0, -1]]
-
-    # clip to the dimension of input
+    if tol.strict and not close:
+        arc_dist = util.row_norm(points[[0, -1]] - discrete[[0, -1]])
+        arc_ok = (arc_dist < tol.merge).all()
+        if not arc_ok:
+            log.warning(
+                "failed to discretize arc (endpoint_distance=%s R=%s)", str(arc_dist), R
+            )
+            log.warning("Failed arc points: %s", str(points))
+            raise ValueError("Arc endpoints diverging!")
     discrete = discrete[:, : (3 - is_2D)]
 
     return discrete
diff --git a/trimesh/path/curve.py b/trimesh/path/curve.py
index 9cbb6d22..90c05923 100644
--- a/trimesh/path/curve.py
+++ b/trimesh/path/curve.py
@@ -2,7 +2,6 @@ import numpy as np
 
 from ..constants import res_path as res
 from ..constants import tol_path as tol
-from ..typed import Integer, List
 
 
 def discretize_bezier(points, count=None, scale=1.0):
@@ -47,15 +46,10 @@ def discretize_bezier(points, count=None, scale=1.0):
     ]
     result = np.sum(stacked, axis=0)
 
-    # a bezier curve always starts and ends on control points
-    if tol.strict:
-        # test to make sure end points are correct
-        test = np.sum((result[[0, -1]] - points[[0, -1]]) ** 2, axis=1)
-        assert (test < tol.merge).all()
-        assert len(result) >= 2
-
-    # snap the first and last points to the exact control point
-    result[[0, -1]] = points[[0, -1]]
+    # test to make sure end points are correct
+    test = np.sum((result[[0, -1]] - points[[0, -1]]) ** 2, axis=1)
+    assert (test < tol.merge).all()
+    assert len(result) >= 2
 
     return result
 
@@ -104,7 +98,7 @@ def discretize_bspline(control, knots, count=None, scale=1.0):
     return discrete
 
 
-def binomial(n: Integer) -> List:
+def binomial(n):
     """
     Return all binomial coefficients for a given order.
 
diff --git a/trimesh/path/exchange/misc.py b/trimesh/path/exchange/misc.py
index 9c977071..d4bb3dd0 100644
--- a/trimesh/path/exchange/misc.py
+++ b/trimesh/path/exchange/misc.py
@@ -39,7 +39,7 @@ def dict_to_path(as_dict):
 
 def lines_to_path(lines: ArrayLike, index: Optional[NDArray[np.int64]] = None) -> Dict:
     """
-    Turn line segments into a Path2D or Path3D object.
+    Turn line segments into argument to be used for a Path2D or Path3D.
 
     Parameters
     ------------
@@ -50,7 +50,7 @@ def lines_to_path(lines: ArrayLike, index: Optional[NDArray[np.int64]] = None) -
 
     Returns
     -----------
-    kwargs : dict
+    kwargs : Dict
       kwargs for Path constructor
     """
     lines = np.asanyarray(lines, dtype=np.float64)
@@ -120,25 +120,29 @@ def polygon_to_path(polygon):
     return kwargs
 
 
-def linestrings_to_path(multi):
+def linestrings_to_path(multi) -> Dict:
     """
-    Load shapely LineString objects into a trimesh.path.Path2D object
+    Load shapely LineString objects into arguments to create a Path2D or Path3D.
 
     Parameters
     -------------
     multi : shapely.geometry.LineString or MultiLineString
-      Input 2D geometry
+      Input 2D or 3D geometry
 
     Returns
     -------------
-    kwargs : dict
-      Keyword arguments for Path2D constructor
+    kwargs : Dict
+      Keyword arguments for Path2D or Path3D constructor
     """
+    import shapely
+
     # append to result as we go
     entities = []
     vertices = []
 
-    if not util.is_sequence(multi):
+    if isinstance(multi, shapely.MultiLineString):
+        multi = list(multi.geoms)
+    else:
         multi = [multi]
 
     for line in multi:
diff --git a/trimesh/triangles.py b/trimesh/triangles.py
index 542337e8..8920a625 100644
--- a/trimesh/triangles.py
+++ b/trimesh/triangles.py
@@ -62,9 +62,6 @@ def area(triangles=None, crosses=None):
     """
     if crosses is None:
         crosses = cross(np.asanyarray(triangles, dtype=np.float64))
-    if len(crosses.shape) == 1:
-        # support 2D triangles
-        return np.abs(crosses) / 2.0
     return np.sqrt((crosses**2).sum(axis=1)) / 2.0
 
 
@@ -89,10 +86,7 @@ def normals(triangles=None, crosses=None):
     if triangles is not None:
         triangles = np.asanyarray(triangles, dtype=np.float64)
         if triangles.shape[-1] == 2:
-            # 2D triangles return unit normal along Z
-            unit = np.tile([0.0, 0.0, 1.0], (triangles.shape[0], 1))
-            valid = np.ones(len(triangles), dtype=bool)
-            return unit, valid
+            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
@@ -381,7 +375,7 @@ def bounds_tree(triangles):
       One node per triangle
     """
     triangles = np.asanyarray(triangles, dtype=np.float64)
-    if not util.is_shape(triangles, (-1, 3, (2, 3))):
+    if not util.is_shape(triangles, (-1, 3, 3)):
         raise ValueError("Triangles must be (n, 3, 3)!")
 
     # the (n,6) interleaved bounding box for every triangle
diff --git a/trimesh/visual/color.py b/trimesh/visual/color.py
index 85ebd0ba..e511d867 100644
--- a/trimesh/visual/color.py
+++ b/trimesh/visual/color.py
@@ -924,14 +924,12 @@ def interpolate(
 
     # make input always float
     values = np.asanyarray(values, dtype=np.float64).ravel()
-
-    # get both minumium and maximum values for range normalization
-    v_min, v_max = values.min(), values.max()
     # offset to zero
-    values -= v_min
-    # normalize to the 0.0 - 1.0 range
-    if v_min != v_max:
-        values /= v_max - v_min
+    values -= values.min()
+    # get the value range to avoid dividing by zero
+    values_ptp = np.ptp(values)
+    if values_ptp > 0.0:
+        values /= values_ptp
 
     # scale values to 0.0 - 1.0 and get colors
     colors = cmap(values)


Test patch:
diff --git a/tests/test_bounds.py b/tests/test_bounds.py
index 546c9787..f4649be3 100644
--- a/tests/test_bounds.py
+++ b/tests/test_bounds.py
@@ -20,9 +20,9 @@ class BoundsTest(g.unittest.TestCase):
         stop = g.timeit.default_timer()
 
         # Make sure oriented bound estimation runs within 30 seconds.
-        assert stop - start < 60, (
-            f"Took {stop - start}s to estimate the oriented bounding box."
-        )
+        assert (
+            stop - start < 30
+        ), f"Took {stop - start} seconds to estimate the oriented bounding box."
 
     def test_obb_mesh(self):
         """
diff --git a/tests/test_creation.py b/tests/test_creation.py
index 0567c49d..c4b20a86 100644
--- a/tests/test_creation.py
+++ b/tests/test_creation.py
@@ -324,9 +324,9 @@ class CreationTest(g.unittest.TestCase):
         mesh180 = g.trimesh.creation.revolve(
             cross_section, g.np.pi, sections=180, cap=True
         )
-        assert g.np.isclose(mesh180.volume, mesh360.volume / 2, rtol=0.1), (
-            "mesh180 should be half of mesh360 volume"
-        )
+        assert g.np.isclose(
+            mesh180.volume, mesh360.volume / 2, rtol=0.1
+        ), "mesh180 should be half of mesh360 volume"
         assert mesh180.is_volume, "mesh180 should be a valid volume"
 
 
diff --git a/tests/test_exchange.py b/tests/test_exchange.py
new file mode 100644
index 00000000..30d2acca
--- /dev/null
+++ b/tests/test_exchange.py
@@ -0,0 +1,38 @@
+from trimesh.path.exchange.misc import linestrings_to_path
+from trimesh.path.exchange.load import load_path
+
+from shapely.geometry import LineString, MultiLineString
+
+
+def test_linestrings_to_path():
+    line = LineString([(0, 0), (1, 1), (2, 0)])
+
+    result = linestrings_to_path(line)
+
+    assert len(result["entities"]) == 1
+    assert len(result["vertices"]) == 3
+
+
+def test_multilinestrings_to_path():
+    line = MultiLineString([
+        LineString([(0, 0), (1, 0), (2, 0)]),
+        LineString([(0, 1), (1, 1), (2, 1)])
+    ])
+
+    result = linestrings_to_path(line)
+
+    assert len(result["entities"]) == 2
+    assert len(result["vertices"]) == 6
+
+
+def test_load_path_with_multilinestrings():
+    line = MultiLineString([
+        LineString([(0, 0), (1, 0), (2, 0)]),
+        LineString([(0, 1), (1, 1), (2, 1)])
+    ])
+
+    result = load_path(line)
+
+    assert len(result.entities) == 2
+    assert len(result.vertices) == 6
+    assert result.length == 4
diff --git a/tests/test_gltf.py b/tests/test_gltf.py
index c75a5060..2ccd846e 100644
--- a/tests/test_gltf.py
+++ b/tests/test_gltf.py
@@ -815,9 +815,7 @@ class GLTFTest(g.unittest.TestCase):
         magenta = g.np.array([255, 0, 255, 255], dtype=g.np.uint8)
         for color in mesh.visual.vertex_colors:
             is_magenta = g.np.array_equal(color, magenta)
-            assert is_magenta, (
-                f"Imported vertex color is not of expected value: got {color}, expected {magenta}"
-            )
+            assert is_magenta, f"Imported vertex color is not of expected value: got {color}, expected {magenta}"
 
     def test_export_postprocess(self):
         scene = g.trimesh.Scene()
diff --git a/tests/test_loaded.py b/tests/test_loaded.py
index cb6edb2a..09105545 100644
--- a/tests/test_loaded.py
+++ b/tests/test_loaded.py
@@ -31,18 +31,6 @@ class LoaderTest(g.unittest.TestCase):
         assert len(m.faces) > 0
         assert m.area > 1e-5
 
-    def test_load_mesh(self):
-        # test the influence of the parameter `process` for `load_mesh`
-        with open(g.os.path.join(g.dir_models, "featuretype.STL"), "rb") as file_obj:
-            mesh = g.trimesh.load_mesh(file_obj=file_obj, file_type="stl", process=False)
-            # check that number of vertices is not being reduced
-            assert len(mesh.vertices) == 10428
-
-        with open(g.os.path.join(g.dir_models, "featuretype.STL"), "rb") as file_obj:
-            mesh = g.trimesh.load_mesh(file_obj=file_obj, file_type="stl", process=True)
-            # check that number of vertices is being reduced
-            assert len(mesh.vertices) == 1722
-
     def test_fileobj(self):
         # make sure we don't close file objects that were passed
         # check load_mesh
diff --git a/tests/test_mesh.py b/tests/test_mesh.py
index ebc58687..a7047a82 100644
--- a/tests/test_mesh.py
+++ b/tests/test_mesh.py
@@ -8,173 +8,166 @@ except BaseException:
     import generic as g
 
 
-def test_meshes():
-    # make sure we can load everything we think we can
-    formats = g.trimesh.available_formats()
-    assert all(isinstance(i, str) for i in formats)
-    assert all(len(i) > 0 for i in formats)
-    assert all(i in formats for i in ["stl", "ply", "off", "obj", "glb"])
-
-    for mesh in g.get_meshes(raise_error=True):
-        # log file name for debugging
-        file_name = mesh.source.file_name
-
-        # ply files can return PointCloud objects
-        if file_name.startswith("points_"):
-            continue
-
-        g.log.info("Testing %s", file_name)
-        start = {mesh.__hash__(), mesh.__hash__()}
-        assert len(mesh.faces) > 0
-        assert len(mesh.vertices) > 0
-
-        # make sure vertex normals match vertices and are valid
-        assert mesh.vertex_normals.shape == mesh.vertices.shape
-        assert g.np.isfinite(mesh.vertex_normals).all()
-
-        # should be one per vertex
-        assert len(mesh.vertex_faces) == len(mesh.vertices)
-
-        # check some edge properties
-        assert len(mesh.edges) > 0
-        assert len(mesh.edges_unique) > 0
-        assert len(mesh.edges_sorted) == len(mesh.edges)
-        assert len(mesh.edges_face) == len(mesh.edges)
-
-        # check edges_unique
-        assert len(mesh.edges) == len(mesh.edges_unique_inverse)
-        assert g.np.allclose(
-            mesh.edges_sorted, mesh.edges_unique[mesh.edges_unique_inverse]
-        )
-        assert len(mesh.edges_unique) == len(mesh.edges_unique_length)
-
-        # euler number should be an integer
-        assert isinstance(mesh.euler_number, int)
-
-        # check bounding primitives
-        assert mesh.bounding_box.volume > 0.0
-        assert mesh.bounding_primitive.volume > 0.0
-
-        # none of these should have mutated anything
-        assert start == {mesh.__hash__(), mesh.__hash__()}
-
-        # run processing, again
-        mesh.process()
-
-        # still shouldn't have changed anything
-        assert start == {mesh.__hash__(), mesh.__hash__()}
-
-        if not (mesh.is_watertight and mesh.is_winding_consistent):
-            continue
-
-        assert len(mesh.facets) == len(mesh.facets_area)
-        assert len(mesh.facets) == len(mesh.facets_normal)
-        assert len(mesh.facets) == len(mesh.facets_boundary)
-
-        if len(mesh.facets) != 0:
-            faces = mesh.facets[mesh.facets_area.argmax()]
-            outline = mesh.outline(faces)
-            # check to make sure we can generate closed paths
-            # on a Path3D object
-            test = outline.paths  # NOQA
-
-        smoothed = mesh.smooth_shaded  # NOQA
-
-        assert abs(mesh.volume) > 0.0
-
-        mesh.section(plane_normal=[0, 0, 1], plane_origin=mesh.centroid)
-
-        sample = mesh.sample(1000)
-        even_sample = g.trimesh.sample.sample_surface_even(mesh, 100)  # NOQA
-        assert sample.shape == (1000, 3)
-        g.log.info("finished testing meshes")
-
-        # make sure vertex kdtree and triangles rtree exist
-
-        t = mesh.kdtree
-        assert hasattr(t, "query")
-        g.log.info("Creating triangles tree")
-        r = mesh.triangles_tree
-        assert hasattr(r, "intersection")
-        g.log.info("Triangles tree ok")
-
-        # face angles should have same
-        assert mesh.face_angles.shape == mesh.faces.shape
-        assert len(mesh.vertices) == len(mesh.vertex_defects)
-        assert len(mesh.principal_inertia_components) == 3
-
-        # make a ray query which may lead to unpicklable caching
-        dimension = (100, 3)
-        ray_origins = g.random(dimension)
-        ray_directions = g.np.tile([0, 0, 1], (dimension[0], 1))
-        ray_origins[:, 2] = mesh.bounds[0][2] - mesh.scale
-
-        # call additional C objects
-        assert mesh.kdtree is not None
-        assert mesh.triangles_tree is not None
-
-        # force ray object to be created
-        ray = mesh.ray.intersects_location(ray_origins, ray_directions)
-        assert ray is not None
-
-        # collect list of cached properties that are writeable
-        writeable = []
-
-        # make sure a roundtrip pickle works
-        # if the cache has non-pickleable stuff this will break
-        pickle = g.pickle.dumps(mesh)
-        assert isinstance(pickle, bytes)
-        assert len(pickle) > 0
-
-        r = g.pickle.loads(pickle)
-        assert r.faces.shape == mesh.faces.shape
-        assert g.np.isclose(r.volume, mesh.volume)
-
-        # we should have built up a bunch of stuff into
-        # our cache, so make sure all numpy arrays cached
-        # are read-only and not crazy
-        for name, cached in mesh._cache.cache.items():
-            # only check numpy arrays
-            if not isinstance(cached, g.np.ndarray):
+class MeshTests(g.unittest.TestCase):
+    def test_meshes(self):
+        # make sure we can load everything we think we can
+        formats = g.trimesh.available_formats()
+        assert all(isinstance(i, str) for i in formats)
+        assert all(len(i) > 0 for i in formats)
+        assert all(i in formats for i in ["stl", "ply", "off", "obj"])
+
+        for mesh in g.get_meshes(raise_error=True):
+            # log file name for debugging
+            file_name = mesh.source.file_name
+
+            # ply files can return PointCloud objects
+            if file_name.startswith("points_"):
                 continue
 
-            # nothing in the cache should be writeable
-            if cached.flags["WRITEABLE"]:
-                raise ValueError(f"{name} is writeable!")
+            g.log.info("Testing %s", file_name)
+            start = {mesh.__hash__(), mesh.__hash__()}
+            assert len(mesh.faces) > 0
+            assert len(mesh.vertices) > 0
 
-            # only check int, float, and bool
-            if cached.dtype.kind not in "ibf":
-                continue
+            # make sure vertex normals match vertices and are valid
+            assert mesh.vertex_normals.shape == mesh.vertices.shape
+            assert g.np.isfinite(mesh.vertex_normals).all()
 
-            # there should never be NaN values
-            if g.np.isnan(cached).any():
-                raise ValueError("NaN values in %s/%s", file_name, name)
+            # should be one per vertex
+            assert len(mesh.vertex_faces) == len(mesh.vertices)
 
-            # fields allowed to have infinite values
-            if name in ["face_adjacency_radius"]:
-                continue
+            # check some edge properties
+            assert len(mesh.edges) > 0
+            assert len(mesh.edges_unique) > 0
+            assert len(mesh.edges_sorted) == len(mesh.edges)
+            assert len(mesh.edges_face) == len(mesh.edges)
+
+            # check edges_unique
+            assert len(mesh.edges) == len(mesh.edges_unique_inverse)
+            assert g.np.allclose(
+                mesh.edges_sorted, mesh.edges_unique[mesh.edges_unique_inverse]
+            )
+            assert len(mesh.edges_unique) == len(mesh.edges_unique_length)
 
-            # make sure everything is finite
-            if not g.np.isfinite(cached).all():
-                raise ValueError("inf values in %s/%s", file_name, name)
+            # euler number should be an integer
+            assert isinstance(mesh.euler_number, int)
 
-        # ...still shouldn't have changed anything
-        assert start == {mesh.__hash__(), mesh.__hash__()}
+            # check bounding primitives
+            assert mesh.bounding_box.volume > 0.0
+            assert mesh.bounding_primitive.volume > 0.0
 
-        # log the names of properties we need to make read-only
-        if len(writeable) > 0:
-            # TODO : all cached values should be read-only
-            g.log.error("cached properties writeable: {}".format(", ".join(writeable)))
+            # none of these should have mutated anything
+            assert start == {mesh.__hash__(), mesh.__hash__()}
 
+            # run processing, again
+            mesh.process()
+
+            # still shouldn't have changed anything
+            assert start == {mesh.__hash__(), mesh.__hash__()}
+
+            if not (mesh.is_watertight and mesh.is_winding_consistent):
+                continue
 
-def test_mesh_2D():
-    # check a simple mesh with 2D vertices
-    m = g.trimesh.Trimesh(
-        vertices=g.np.random.random((100, 2)),
-        faces=g.np.arange(99, dtype=g.np.int64).reshape((-1, 3)),
-    )
-    # the face normals should be 3D (+Z)
-    assert m.face_normals.shape == m.faces.shape
+            assert len(mesh.facets) == len(mesh.facets_area)
+            assert len(mesh.facets) == len(mesh.facets_normal)
+            assert len(mesh.facets) == len(mesh.facets_boundary)
+
+            if len(mesh.facets) != 0:
+                faces = mesh.facets[mesh.facets_area.argmax()]
+                outline = mesh.outline(faces)
+                # check to make sure we can generate closed paths
+                # on a Path3D object
+                test = outline.paths  # NOQA
+
+            smoothed = mesh.smooth_shaded  # NOQA
+
+            assert abs(mesh.volume) > 0.0
+
+            mesh.section(plane_normal=[0, 0, 1], plane_origin=mesh.centroid)
+
+            sample = mesh.sample(1000)
+            even_sample = g.trimesh.sample.sample_surface_even(mesh, 100)  # NOQA
+            assert sample.shape == (1000, 3)
+            g.log.info("finished testing meshes")
+
+            # make sure vertex kdtree and triangles rtree exist
+
+            t = mesh.kdtree
+            assert hasattr(t, "query")
+            g.log.info("Creating triangles tree")
+            r = mesh.triangles_tree
+            assert hasattr(r, "intersection")
+            g.log.info("Triangles tree ok")
+
+            # face angles should have same
+            assert mesh.face_angles.shape == mesh.faces.shape
+            assert len(mesh.vertices) == len(mesh.vertex_defects)
+            assert len(mesh.principal_inertia_components) == 3
+
+            # make a ray query which may lead to unpicklable caching
+            dimension = (100, 3)
+            ray_origins = g.random(dimension)
+            ray_directions = g.np.tile([0, 0, 1], (dimension[0], 1))
+            ray_origins[:, 2] = mesh.bounds[0][2] - mesh.scale
+
+            # call additional C objects
+            assert mesh.kdtree is not None
+            assert mesh.triangles_tree is not None
+
+            # force ray object to be created
+            ray = mesh.ray.intersects_location(ray_origins, ray_directions)
+            assert ray is not None
+
+            # collect list of cached properties that are writeable
+            writeable = []
+
+            # make sure a roundtrip pickle works
+            # if the cache has non-pickleable stuff this will break
+            pickle = g.pickle.dumps(mesh)
+            assert isinstance(pickle, bytes)
+            assert len(pickle) > 0
+
+            r = g.pickle.loads(pickle)
+            assert r.faces.shape == mesh.faces.shape
+            assert g.np.isclose(r.volume, mesh.volume)
+
+            # we should have built up a bunch of stuff into
+            # our cache, so make sure all numpy arrays cached
+            # are read-only and not crazy
+            for name, cached in mesh._cache.cache.items():
+                # only check numpy arrays
+                if not isinstance(cached, g.np.ndarray):
+                    continue
+
+                # nothing in the cache should be writeable
+                if cached.flags["WRITEABLE"]:
+                    raise ValueError(f"{name} is writeable!")
+
+                # only check int, float, and bool
+                if cached.dtype.kind not in "ibf":
+                    continue
+
+                # there should never be NaN values
+                if g.np.isnan(cached).any():
+                    raise ValueError("NaN values in %s/%s", file_name, name)
+
+                # fields allowed to have infinite values
+                if name in ["face_adjacency_radius"]:
+                    continue
+
+                # make sure everything is finite
+                if not g.np.isfinite(cached).all():
+                    raise ValueError("inf values in %s/%s", file_name, name)
+
+            # ...still shouldn't have changed anything
+            assert start == {mesh.__hash__(), mesh.__hash__()}
+
+            # log the names of properties we need to make read-only
+            if len(writeable) > 0:
+                # TODO : all cached values should be read-only
+                g.log.error(
+                    "cached properties writeable: {}".format(", ".join(writeable))
+                )
 
 
 if __name__ == "__main__":
diff --git a/tests/test_paths.py b/tests/test_paths.py
index bffab2ea..506dc40a 100644
--- a/tests/test_paths.py
+++ b/tests/test_paths.py
@@ -4,335 +4,319 @@ except BaseException:
     import generic as g
 
 
-def test_discrete():
-    for d in g.get_2D():
-        # store hash before requesting passive functions
-        hash_val = d.__hash__()
-
-        assert isinstance(d.identifier, g.np.ndarray)
-        assert isinstance(d.identifier_hash, str)
-
-        # make sure various methods return
-        # basically the same bounds
-        atol = d.scale / 1000
-        for dis, pa, pl in zip(d.discrete, d.paths, d.polygons_closed):
-            # bounds of discrete version of path
-            bd = g.np.array([g.np.min(dis, axis=0), g.np.max(dis, axis=0)])
-            # bounds of polygon version of path
-            bl = g.np.reshape(pl.bounds, (2, 2))
-            # try bounds of included entities from path
-            pad = g.np.vstack([d.entities[i].discrete(d.vertices) for i in pa])
-            bp = g.np.array([g.np.min(pad, axis=0), g.np.max(pad, axis=0)])
-
-            assert g.np.allclose(bd, bl, atol=atol)
-            assert g.np.allclose(bl, bp, atol=atol)
-
-        # run some checks
-        g.check_path2D(d)
-
-        # copying shouldn't touch original file
-        copied = d.copy()
-
-        # these operations shouldn't have mutated anything!
-        assert d.__hash__() == hash_val
-
-        # copy should have saved the metadata
-        assert set(copied.metadata.keys()) == set(d.metadata.keys())
-
-        # file_name should be populated, and if we have a DXF file
-        # the layer field should be populated with layer names
-        if d.source.file_name[-3:] == "dxf":
-            assert len(d.layers) == len(d.entities)
-
-        for path, verts in zip(d.paths, d.discrete):
-            assert len(path) >= 1
-            dists = g.np.sum((g.np.diff(verts, axis=0)) ** 2, axis=1) ** 0.5
-
-            if not g.np.all(dists > g.tol_path.zero):
-                raise ValueError("{} had zero distance in discrete!", d.source.file_name)
-
-            circuit_dist = g.np.linalg.norm(verts[0] - verts[-1])
-            circuit_test = circuit_dist < g.tol_path.merge
-            if not circuit_test:
-                g.log.error(
-                    "On file %s First and last vertex distance %f",
-                    d.source.file_name,
-                    circuit_dist,
-                )
-            assert circuit_test
-
-            is_ccw = g.trimesh.path.util.is_ccw(verts)
-            if not is_ccw:
-                g.log.error("discrete %s not ccw!", d.source.file_name)
-
-        for i in range(len(d.paths)):
-            assert d.polygons_closed[i].is_valid
-            assert d.polygons_closed[i].area > g.tol_path.zero
-        export_dict = d.export(file_type="dict")
-        to_dict = d.to_dict()
-        assert isinstance(to_dict, dict)
-        assert isinstance(export_dict, dict)
-        assert len(to_dict) == len(export_dict)
-
-        export_svg = d.export(file_type="svg")  # NOQA
-        simple = d.simplify()  # NOQA
-        split = d.split()
-        g.log.info(
-            "Split %s into %d bodies, checking identifiers",
-            d.source.file_name,
-            len(split),
-        )
-        for body in split:
-            _ = body.identifier
-
-        if len(d.root) == 1:
-            d.apply_obb()
-
-        # store the X values of bounds
-        ori = d.bounds.copy()
-        # apply a translation
-        d.apply_translation([10, 0])
-        # X should have translated by 10.0
-        assert g.np.allclose(d.bounds[:, 0] - 10, ori[:, 0])
-        # Y should not have moved
-        assert g.np.allclose(d.bounds[:, 1], ori[:, 1])
-
-        if len(d.polygons_full) > 0 and len(d.vertices) < 150:
-            g.log.info("Checking medial axis on %s", d.source.file_name)
-            m = d.medial_axis()
-            assert len(m.entities) > 0
-
-        # shouldn't crash
-        d.fill_gaps()
-
-        # transform to first quadrant
-        d.rezero()
-        # run process manually
-        d.process()
-
-
-def test_poly():
-    p = g.get_mesh("2D/LM2.dxf")
-    assert p.is_closed
-
-    # one of the lines should be a polyline
-    assert any(
-        len(e.points) > 2
-        for e in p.entities
-        if isinstance(e, g.trimesh.path.entities.Line)
-    )
-
-    # layers should match entity count
-    assert len(p.layers) == len(p.entities)
-    assert len(set(p.layers)) > 1
-
-    count = len(p.entities)
-
-    p.explode()
-    # explode should have created new entities
-    assert len(p.entities) > count
-    # explode should have added some new layers
-    assert len(p.entities) == len(p.layers)
-    # all line segments should have two points now
-    assert all(
-        len(i.points) == 2
-        for i in p.entities
-        if isinstance(i, g.trimesh.path.entities.Line)
-    )
-    # should still be closed
-    assert p.is_closed
-    # chop off the last entity
-    p.entities = p.entities[:-1]
-    # should no longer be closed
-    assert not p.is_closed
-
-    # fill gaps of any distance
-    p.fill_gaps(g.np.inf)
-    # should have fixed this puppy
-    assert p.is_closed
-
-    # remove 2 short edges using remove_entity()
-    count = len(p.entities)
-    p.remove_entities([count - 1, count - 6])
-    assert not p.is_closed
-    p.fill_gaps(2)
-    assert p.is_closed
-
-
-def test_text():
-    """
-    Do some checks on Text entities
-    """
-    p = g.get_mesh("2D/LM2.dxf")
-    p.explode()
-    # get some text entities
-    text = [e for e in p.entities if isinstance(e, g.trimesh.path.entities.Text)]
-    assert len(text) > 1
-
-    # loop through each of them
-    for t in text:
-        # a spurious error we were seeing in CI
-        if g.trimesh.util.is_instance_named(t, "Line"):
-            raise ValueError(
-                "type bases:", [i.__name__ for i in g.trimesh.util.type_bases(t)]
+class VectorTests(g.unittest.TestCase):
+    def test_discrete(self):
+        for d in g.get_2D():
+            # store hash before requesting passive functions
+            hash_val = d.__hash__()
+
+            assert isinstance(d.identifier, g.np.ndarray)
+            assert isinstance(d.identifier_hash, str)
+
+            # make sure various methods return
+            # basically the same bounds
+            atol = d.scale / 1000
+            for dis, pa, pl in zip(d.discrete, d.paths, d.polygons_closed):
+                # bounds of discrete version of path
+                bd = g.np.array([g.np.min(dis, axis=0), g.np.max(dis, axis=0)])
+                # bounds of polygon version of path
+                bl = g.np.reshape(pl.bounds, (2, 2))
+                # try bounds of included entities from path
+                pad = g.np.vstack([d.entities[i].discrete(d.vertices) for i in pa])
+                bp = g.np.array([g.np.min(pad, axis=0), g.np.max(pad, axis=0)])
+
+                assert g.np.allclose(bd, bl, atol=atol)
+                assert g.np.allclose(bl, bp, atol=atol)
+
+            # run some checks
+            g.check_path2D(d)
+
+            # copying shouldn't touch original file
+            copied = d.copy()
+
+            # these operations shouldn't have mutated anything!
+            assert d.__hash__() == hash_val
+
+            # copy should have saved the metadata
+            assert set(copied.metadata.keys()) == set(d.metadata.keys())
+
+            # file_name should be populated, and if we have a DXF file
+            # the layer field should be populated with layer names
+            if d.source.file_name[-3:] == "dxf":
+                assert len(d.layers) == len(d.entities)
+
+            for path, verts in zip(d.paths, d.discrete):
+                assert len(path) >= 1
+                dists = g.np.sum((g.np.diff(verts, axis=0)) ** 2, axis=1) ** 0.5
+
+                if not g.np.all(dists > g.tol_path.zero):
+                    raise ValueError(
+                        "{} had zero distance in discrete!", d.source.file_name
+                    )
+
+                circuit_dist = g.np.linalg.norm(verts[0] - verts[-1])
+                circuit_test = circuit_dist < g.tol_path.merge
+                if not circuit_test:
+                    g.log.error(
+                        "On file %s First and last vertex distance %f",
+                        d.source.file_name,
+                        circuit_dist,
+                    )
+                assert circuit_test
+
+                is_ccw = g.trimesh.path.util.is_ccw(verts)
+                if not is_ccw:
+                    g.log.error("discrete %s not ccw!", d.source.file_name)
+
+            for i in range(len(d.paths)):
+                assert d.polygons_closed[i].is_valid
+                assert d.polygons_closed[i].area > g.tol_path.zero
+            export_dict = d.export(file_type="dict")
+            to_dict = d.to_dict()
+            assert isinstance(to_dict, dict)
+            assert isinstance(export_dict, dict)
+            assert len(to_dict) == len(export_dict)
+
+            export_svg = d.export(file_type="svg")  # NOQA
+            simple = d.simplify()  # NOQA
+            split = d.split()
+            g.log.info(
+                "Split %s into %d bodies, checking identifiers",
+                d.source.file_name,
+                len(split),
             )
-    # make sure this doesn't crash with text entities
-    g.trimesh.rendering.convert_to_vertexlist(p)
+            for body in split:
+                _ = body.identifier
+
+            if len(d.root) == 1:
+                d.apply_obb()
+
+            # store the X values of bounds
+            ori = d.bounds.copy()
+            # apply a translation
+            d.apply_translation([10, 0])
+            # X should have translated by 10.0
+            assert g.np.allclose(d.bounds[:, 0] - 10, ori[:, 0])
+            # Y should not have moved
+            assert g.np.allclose(d.bounds[:, 1], ori[:, 1])
+
+            if len(d.polygons_full) > 0 and len(d.vertices) < 150:
+                g.log.info("Checking medial axis on %s", d.source.file_name)
+                m = d.medial_axis()
+                assert len(m.entities) > 0
+
+            # shouldn't crash
+            d.fill_gaps()
+
+            # transform to first quadrant
+            d.rezero()
+            # run process manually
+            d.process()
+
+    def test_poly(self):
+        p = g.get_mesh("2D/LM2.dxf")
+        assert p.is_closed
+
+        # one of the lines should be a polyline
+        assert any(
+            len(e.points) > 2
+            for e in p.entities
+            if isinstance(e, g.trimesh.path.entities.Line)
+        )
 
-
-def test_empty():
-    # make sure empty paths perform as expected
-
-    p = g.trimesh.path.Path2D()
-    assert p.is_empty
-    p = g.trimesh.path.Path3D()
-    assert p.is_empty
-
-    p = g.trimesh.load_path([[[0, 0, 1], [1, 0, 1]]])
-    assert len(p.entities) == 1
-    assert not p.is_empty
-
-    b = p.to_planar()[0]
-    assert len(b.entities) == 1
-    assert not b.is_empty
-
-
-def test_color():
-    p = g.get_mesh("2D/wrench.dxf")
-    # make sure we have entities
-    assert len(p.entities) > 0
-    # make sure shape of colors is correct
-    assert all(e.color is None for e in p.entities)
-    assert p.colors is None
-
-    color = [255, 0, 0, 255]
-    # assign a color to the entity
-    p.entities[0].color = color
-    # make sure this is reflected in the path color
-    assert g.np.allclose(p.colors[0], color)
-    assert p.colors.shape == (len(p.entities), 4)
-
-    p.colors = g.np.array([100, 100, 100] * len(p.entities), dtype=g.np.uint8).reshape(
-        (-1, 3)
-    )
-    assert g.np.allclose(p.colors[0], [100, 100, 100, 255])
-
-
-def test_dangling():
-    p = g.get_mesh("2D/wrench.dxf")
-    assert len(p.dangling) == 0
-
-    b = g.get_mesh("2D/loose.dxf")
-    assert len(b.dangling) == 1
-    assert len(b.polygons_full) == 1
-
-
-def test_plot():
-    try:
-        # only run these if matplotlib is installed
-        import matplotlib.pyplot  # NOQA
-    except BaseException:
-        g.log.debug("skipping `matplotlib.pyplot` tests")
-        return
-
-    p = g.get_mesh("2D/wrench.dxf")
-
-    # see if the logic crashes
-    p.plot_entities(show=False)
-    p.plot_discrete(show=False)
-
-
-def test_split():
-    for fn in [
-        "2D/ChuteHolderPrint.DXF",
-        "2D/tray-easy1.dxf",
-        "2D/sliding-base.dxf",
-        "2D/wrench.dxf",
-        "2D/spline_1.dxf",
-    ]:
-        p = g.get_mesh(fn)
-
-        # make sure something was loaded
-        assert len(p.root) > 0
-
-        # split by connected
-        split = p.split()
-
-        # make sure split parts have same area as source
-        assert g.np.isclose(p.area, sum(i.area for i in split))
-        # make sure concatenation doesn't break that
-        assert g.np.isclose(p.area, g.np.sum(split).area)
-
-        # check that cache didn't screw things up
-        for s in split:
-            assert len(s.root) == 1
-            assert len(s.path_valid) == len(s.paths)
-            assert len(s.paths) == len(s.discrete)
-            assert s.path_valid.sum() == len(s.polygons_closed)
-            g.check_path2D(s)
-
-
-def test_section():
-    mesh = g.get_mesh("tube.obj")
-
-    # check the CCW correctness with a normal in both directions
-    for sign in [1.0, -1.0]:
-        # get a cross section of the tube
-        section = mesh.section(
-            plane_origin=mesh.center_mass, plane_normal=[0.0, sign, 0.0]
+        # layers should match entity count
+        assert len(p.layers) == len(p.entities)
+        assert len(set(p.layers)) > 1
+
+        count = len(p.entities)
+
+        p.explode()
+        # explode should have created new entities
+        assert len(p.entities) > count
+        # explode should have added some new layers
+        assert len(p.entities) == len(p.layers)
+        # all line segments should have two points now
+        assert all(
+            len(i.points) == 2
+            for i in p.entities
+            if isinstance(i, g.trimesh.path.entities.Line)
         )
+        # should still be closed
+        assert p.is_closed
+        # chop off the last entity
+        p.entities = p.entities[:-1]
+        # should no longer be closed
+        assert not p.is_closed
+
+        # fill gaps of any distance
+        p.fill_gaps(g.np.inf)
+        # should have fixed this puppy
+        assert p.is_closed
+
+        # remove 2 short edges using remove_entity()
+        count = len(p.entities)
+        p.remove_entities([count - 1, count - 6])
+        assert not p.is_closed
+        p.fill_gaps(2)
+        assert p.is_closed
+
+    def test_text(self):
+        """
+        Do some checks on Text entities
+        """
+        p = g.get_mesh("2D/LM2.dxf")
+        p.explode()
+        # get some text entities
+        text = [e for e in p.entities if isinstance(e, g.trimesh.path.entities.Text)]
+        assert len(text) > 1
+
+        # loop through each of them
+        for t in text:
+            # a spurious error we were seeing in CI
+            if g.trimesh.util.is_instance_named(t, "Line"):
+                raise ValueError(
+                    "type bases:", [i.__name__ for i in g.trimesh.util.type_bases(t)]
+                )
+        # make sure this doesn't crash with text entities
+        g.trimesh.rendering.convert_to_vertexlist(p)
+
+    def test_empty(self):
+        # make sure empty paths perform as expected
+
+        p = g.trimesh.path.Path2D()
+        assert p.is_empty
+        p = g.trimesh.path.Path3D()
+        assert p.is_empty
+
+        p = g.trimesh.load_path([[[0, 0, 1], [1, 0, 1]]])
+        assert len(p.entities) == 1
+        assert not p.is_empty
+
+        b = p.to_planar()[0]
+        assert len(b.entities) == 1
+        assert not b.is_empty
+
+    def test_color(self):
+        p = g.get_mesh("2D/wrench.dxf")
+        # make sure we have entities
+        assert len(p.entities) > 0
+        # make sure shape of colors is correct
+        assert all(e.color is None for e in p.entities)
+        assert p.colors is None
+
+        color = [255, 0, 0, 255]
+        # assign a color to the entity
+        p.entities[0].color = color
+        # make sure this is reflected in the path color
+        assert g.np.allclose(p.colors[0], color)
+        assert p.colors.shape == (len(p.entities), 4)
+
+        p.colors = g.np.array(
+            [100, 100, 100] * len(p.entities), dtype=g.np.uint8
+        ).reshape((-1, 3))
+        assert g.np.allclose(p.colors[0], [100, 100, 100, 255])
+
+    def test_dangling(self):
+        p = g.get_mesh("2D/wrench.dxf")
+        assert len(p.dangling) == 0
+
+        b = g.get_mesh("2D/loose.dxf")
+        assert len(b.dangling) == 1
+        assert len(b.polygons_full) == 1
+
+    def test_plot(self):
+        try:
+            # only run these if matplotlib is installed
+            import matplotlib.pyplot  # NOQA
+        except BaseException:
+            g.log.debug("skipping `matplotlib.pyplot` tests")
+            return
+
+        p = g.get_mesh("2D/wrench.dxf")
+
+        # see if the logic crashes
+        p.plot_entities(show=False)
+        p.plot_discrete(show=False)
+
+
+class SplitTest(g.unittest.TestCase):
+    def test_split(self):
+        for fn in [
+            "2D/ChuteHolderPrint.DXF",
+            "2D/tray-easy1.dxf",
+            "2D/sliding-base.dxf",
+            "2D/wrench.dxf",
+            "2D/spline_1.dxf",
+        ]:
+            p = g.get_mesh(fn)
+
+            # make sure something was loaded
+            assert len(p.root) > 0
+
+            # split by connected
+            split = p.split()
+
+            # make sure split parts have same area as source
+            assert g.np.isclose(p.area, sum(i.area for i in split))
+            # make sure concatenation doesn't break that
+            assert g.np.isclose(p.area, g.np.sum(split).area)
+
+            # check that cache didn't screw things up
+            for s in split:
+                assert len(s.root) == 1
+                assert len(s.path_valid) == len(s.paths)
+                assert len(s.paths) == len(s.discrete)
+                assert s.path_valid.sum() == len(s.polygons_closed)
+                g.check_path2D(s)
+
+
+class SectionTest(g.unittest.TestCase):
+    def test_section(self):
+        mesh = g.get_mesh("tube.obj")
+
+        # check the CCW correctness with a normal in both directions
+        for sign in [1.0, -1.0]:
+            # get a cross section of the tube
+            section = mesh.section(
+                plane_origin=mesh.center_mass, plane_normal=[0.0, sign, 0.0]
+            )
 
-        # Path3D -> Path2D
-        planar, _T = section.to_planar()
-
-        # tube should have one closed polygon
-        assert len(planar.polygons_full) == 1
-        polygon = planar.polygons_full[0]
-        # closed polygon should have one interior
-        assert len(polygon.interiors) == 1
-
-        # the exterior SHOULD be counterclockwise
-        assert g.trimesh.path.util.is_ccw(polygon.exterior.coords)
-        # the interior should NOT be counterclockwise
-        assert not g.trimesh.path.util.is_ccw(polygon.interiors[0].coords)
-
-        # should be a valid Path2D
-        g.check_path2D(planar)
-
-
-def test_multiplane():
-    # check to make sure we're applying `tol_path.merge_digits` for line segments
-    vertices = g.np.array(
-        [
-            [19.402250931139097, -14.88787016674277, 64.0],
-            [20.03318396099334, -14.02738654377374, 64.0],
-            [19.402250931139097, -14.88787016674277, 0.0],
-            [20.03318396099334, -14.02738654377374, 0.0],
-            [21, -16.5, 32],
-        ]
-    )
-    faces = g.np.array([[1, 3, 0], [0, 3, 2], [1, 0, 4], [0, 2, 4], [3, 1, 4], [2, 3, 4]])
-    z_layer_centers = g.np.array([3, 3.0283334255218506])
-    m = g.trimesh.Trimesh(vertices, faces)
-    r = m.section_multiplane([0, 0, 0], [0, 0, 1], z_layer_centers)
-    assert len(r) == 2
-    assert all(i.is_closed for i in r)
-
-
-def test_svg_arc_snap():
-    s = """<svg xmlns="http://www.w3.org/2000/svg" width="97.5mm" height="35mm" viewBox="-17.5 0 97.5 35">
-
-<path d="M 0 0 h 80 v 35 h -80 a 17.5 17.5 0 0 1 0 -35 z" fill="none" stroke="black" stroke-width="0.1" />
-</svg>"""
-
-    p = g.trimesh.load_path(g.trimesh.util.wrap_as_stream(s), file_type="svg")
-
-    # should be one closed path
-    assert len(p.root) == 1
-    # should be exactly closed with a snap
-    assert g.np.equal(*p.discrete[0][[0, -1]]).all()
-    # the extrusion should be a volume
-    assert p.extrude(10.0).is_volume
+            # Path3D -> Path2D
+            planar, _T = section.to_planar()
+
+            # tube should have one closed polygon
+            assert len(planar.polygons_full) == 1
+            polygon = planar.polygons_full[0]
+            # closed polygon should have one interior
+            assert len(polygon.interiors) == 1
+
+            # the exterior SHOULD be counterclockwise
+            assert g.trimesh.path.util.is_ccw(polygon.exterior.coords)
+            # the interior should NOT be counterclockwise
+            assert not g.trimesh.path.util.is_ccw(polygon.interiors[0].coords)
+
+            # should be a valid Path2D
+            g.check_path2D(planar)
+
+    def test_multiplane(self):
+        # check to make sure we're applying `tol_path.merge_digits` for line segments
+        vertices = g.np.array(
+            [
+                [19.402250931139097, -14.88787016674277, 64.0],
+                [20.03318396099334, -14.02738654377374, 64.0],
+                [19.402250931139097, -14.88787016674277, 0.0],
+                [20.03318396099334, -14.02738654377374, 0.0],
+                [21, -16.5, 32],
+            ]
+        )
+        faces = g.np.array(
+            [[1, 3, 0], [0, 3, 2], [1, 0, 4], [0, 2, 4], [3, 1, 4], [2, 3, 4]]
+        )
+        z_layer_centers = g.np.array([3, 3.0283334255218506])
+        m = g.trimesh.Trimesh(vertices, faces)
+        r = m.section_multiplane([0, 0, 0], [0, 0, 1], z_layer_centers)
+        assert len(r) == 2
+        assert all(i.is_closed for i in r)
 
 
 if __name__ == "__main__":