Repository: mikedh/trimesh

Issue:
Handling arcs vs? Beziers curves in paths
Hello,

I use trimesh to generate STLs from various SVGs and it works great. My repository is generating tags based on various fonts and in the tag svg, I have arcs. When creating a mesh from this path, the one that has arcs is not watertight and has no volume:

```
shape_svg = trimesh.load_path(r'./inputs/shape.svg')
shape_svg.fill_gaps()
shape_mesh = shape_svg.extrude(3)

shape_ink_svg = trimesh.load_path(r'./inputs/shape_ink.svg')
shape_ink_mesh = shape_ink_svg.extrude(3)
```

![Image](https://github.com/user-attachments/assets/81994e32-0cb9-44f0-b1d3-100ee3f9eef3)
![Image](https://github.com/user-attachments/assets/e460a721-84f3-4d6c-b6b0-7d7476d941fa)

In order to get the second one, I opened the first one in Inkscape and saved after changing the arcs in Bezier curves.

Wondering of there were limitations due to use in arc in paths or any special things to prepare my inputs.

Several parameters are very similar except the lenght:
```
Comparison:
bounds:
[[-17.49998212   0.        ]
 [ 80.          35.        ]]
-----===-----
[[-17.5   0. ]
 [ 80.   35. ]]


vertices:
[[  0.    0. ]
 [ 80.    0. ]
 [-17.5  17.5]
 [  0.   35. ]
 [ 80.   35. ]]
-----===-----
[[ -9.6649897   0.       ]
 [  0.          0.       ]
 [ 80.          0.       ]
 [-17.5         7.8350171]
 [-17.5        17.5      ]
 [-17.5        27.164983 ]
 [ -9.6649897  35.       ]
 [  0.         35.       ]
 [ 80.         35.       ]]


centroid:
[31.25000894 17.5       ]
-----===-----
[31.25 17.5 ]


length:
304.9557428756427
-----===-----
249.98557190148313


area:
3280.5362900551977
-----===-----
3281.159374448326


identifier:
[0.00000000e+00 3.28053629e+03 2.49963008e+02 3.28053629e+03
 2.49963008e+02 2.49963008e+02 2.42600946e+06 3.22584626e+05]
-----===-----
[0.00000000e+00 3.28115937e+03 2.49984680e+02 3.28115937e+03
 2.49984680e+02 2.49984680e+02 2.42724435e+06 3.22679987e+05]
```

Discussion:
@mikedh: Hey, glad it's useful! Arcs should work fine assuming they're part of a closed contour. Does `fill_gaps(distance=1.0)` or some larger value work? Can you share the SVG that's failing? @PetitPhoenix: Hello,

fill_gaps(distance=1.0) doesn't work and fill_gaps(distance=10) neither.

the source is just above but if you need it, the code is:
```
<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>
```@mikedh: It looks like it's the arc discretization not exactly matching the endpoints:

```

In [18]: p.discrete[0][[0,-1]]
Out[18]:
array([[ 0.00000000e+00,  0.00000000e+00],
       [-2.14313190e-15,  3.55271368e-15]])
```

1e-15 is below the trimesh threshold, but apparently above the earcut triangulation threshold. I added an endpoint snap to both bezier and 3-point arc discretization since both bezier curves and 3-point arcs start and end exactly on their control points and we don't have to approximate.  @PetitPhoenix: Thanks but in 4.6.1 I still have the issue. Not very good with github. Has it been implemented?@mikedh: Hey this fix is in 4.6.2 which just released, the build was blocked overnight by an upstream package which I just fixed. 

Solution patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8f6dcefc4..989a5f091 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -27,8 +27,8 @@ jobs:
     runs-on: ${{ matrix.os }}
     strategy:
       matrix:
-        python-version: ["3.8", "3.9", "3.10", "3.11"]
-        os: [ubuntu-latest, macos-13, windows-latest]
+        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
+        os: [ubuntu-latest, macos-latest, macos-13, windows-latest]
         exclude:
             # windows runners have gotten flaky
           - os: windows-latest
diff --git a/docs/content/install.md b/docs/content/install.md
index c121d5432..3d26a8b96 100644
--- a/docs/content/install.md
+++ b/docs/content/install.md
@@ -46,7 +46,7 @@ Trimesh has a lot of soft-required upstream packages, and we try to make sure th
 |`httpx`| Do network queries in `trimesh.exchange.load_remote`, will *only* make network requests when asked | `requests`, `aiohttp` | `easy`|
 |`sympy`| Evaluate symbolic algebra | | `recommend`|
 |`xxhash`| Quickly hash arrays, used for our cache checking | | `easy`|
-|`chardet`| When we fail to decode text as UTF-8 we then check with chardet which guesses an encoding,  letting us load files even with weird encodings. | | `easy`|
+|`charset-normalizer`| When we fail to decode text as UTF-8 we then check with charset-normalizer which guesses an encoding,  letting us load files even with weird encodings. | | `easy`|
 |`colorlog`| Printing logs with colors. | | `easy`|
 |`pillow`| Reading raster images for textures and render polygons into raster images. | | `easy`|
 |`svg.path`| Parsing SVG path strings. | | `easy`|
diff --git a/pyproject.toml b/pyproject.toml
index 4f1970cd5..ab2721332 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.1"
+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."
@@ -68,7 +68,7 @@ trimesh = [
 easy = [
     "colorlog",
     "manifold3d>=2.3.0", # do boolean operations    
-    "chardet",
+    "charset-normalizer",
     "lxml",
     "jsonschema",
     "networkx",
@@ -149,6 +149,7 @@ select = [
     "UP", # upgrade
     "W", # style warnings
     "YTT", # sys.version
+    "ISC001",
     "ISC002",
     "NPY201",
     "TID251"
diff --git a/trimesh/bounds.py b/trimesh/bounds.py
index 64fc20d30..0215a8b48 100644
--- a/trimesh/bounds.py
+++ b/trimesh/bounds.py
@@ -2,6 +2,7 @@
 
 from . import convex, geometry, grouping, nsphere, transformations, util
 from .constants import log, now
+from .typed import ArrayLike, NDArray
 
 try:
     # scipy is a soft dependency
@@ -548,9 +549,9 @@ def corners(bounds):
     return corners
 
 
-def contains(bounds, points):
+def contains(bounds: ArrayLike, points: ArrayLike) -> NDArray[np.bool_]:
     """
-    Do an axis aligned bounding box check on a list of points.
+    Do an axis aligned bounding box check on an array of points.
 
     Parameters
     -----------
diff --git a/trimesh/creation.py b/trimesh/creation.py
index 97df23a5c..844d915b7 100644
--- a/trimesh/creation.py
+++ b/trimesh/creation.py
@@ -849,7 +849,7 @@ def icosahedron(**kwargs) -> Trimesh:
 
 def icosphere(subdivisions: Integer = 3, radius: Number = 1.0, **kwargs):
     """
-    Create an isophere centered at the origin.
+    Create an icosphere centered at the origin.
 
     Parameters
     ----------
diff --git a/trimesh/exchange/threemf.py b/trimesh/exchange/threemf.py
index 9713cf2db..754121aad 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 5ac765e62..525170805 100644
--- a/trimesh/path/arc.py
+++ b/trimesh/path/arc.py
@@ -194,15 +194,22 @@ 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 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!")
+    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
     discrete = discrete[:, : (3 - is_2D)]
 
     return discrete
diff --git a/trimesh/path/curve.py b/trimesh/path/curve.py
index 90c05923d..9cbb6d222 100644
--- a/trimesh/path/curve.py
+++ b/trimesh/path/curve.py
@@ -2,6 +2,7 @@
 
 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):
@@ -46,10 +47,15 @@ def discretize_bezier(points, count=None, scale=1.0):
     ]
     result = np.sum(stacked, axis=0)
 
-    # 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
+    # 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]]
 
     return result
 
@@ -98,7 +104,7 @@ def discretize_bspline(control, knots, count=None, scale=1.0):
     return discrete
 
 
-def binomial(n):
+def binomial(n: Integer) -> List:
     """
     Return all binomial coefficients for a given order.
 
diff --git a/trimesh/path/exchange/misc.py b/trimesh/path/exchange/misc.py
index 9c977071f..d4bb3dd0e 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/rendering.py b/trimesh/rendering.py
index 1f81f882e..9989b8efc 100644
--- a/trimesh/rendering.py
+++ b/trimesh/rendering.py
@@ -71,13 +71,17 @@ def mesh_to_vertexlist(mesh, group=None, smooth=True, smooth_threshold=60000):
       Args for vertex list constructor
 
     """
+    # nominally support 2D vertices
+    if len(mesh.vertices.shape) == 2 and mesh.vertices.shape[1] == 2:
+        vertices = np.column_stack((mesh.vertices, np.zeros(len(mesh.vertices))))
+    else:
+        vertices = mesh.vertices
 
     if hasattr(mesh.visual, "uv"):
         # if the mesh has texture defined pass it to pyglet
-        vertex_count = len(mesh.vertices)
-        normals = mesh.vertex_normals.reshape(-1).tolist()
-        faces = mesh.faces.reshape(-1).tolist()
-        vertices = mesh.vertices.reshape(-1).tolist()
+        vertex_count = len(vertices)
+        normals = mesh.vertex_normals
+        faces = mesh.faces
 
         # get the per-vertex UV coordinates
         uv = mesh.visual.uv
@@ -108,19 +112,20 @@ def mesh_to_vertexlist(mesh, group=None, smooth=True, smooth_threshold=60000):
         # if we have a small number of faces and colors defined
         # smooth the  mesh by merging vertices of faces below
         # the threshold angle
-        mesh = mesh.smooth_shaded
-        vertex_count = len(mesh.vertices)
-        normals = mesh.vertex_normals.reshape(-1).tolist()
-        faces = mesh.faces.reshape(-1).tolist()
-        vertices = mesh.vertices.reshape(-1).tolist()
+        smooth = mesh.smooth_shaded
+        vertices = smooth.vertices
+        vertex_count = len(vertices)
+        normals = smooth.vertex_normals
+        faces = smooth.faces
+        vertices = smooth.vertices
         color_gl = colors_to_gl(mesh.visual.vertex_colors, vertex_count)
     else:
         # we don't have textures or want to smooth so
         # send a polygon soup of disconnected triangles to opengl
-        vertex_count = len(mesh.triangles) * 3
-        normals = np.tile(mesh.face_normals, (1, 3)).reshape(-1).tolist()
-        vertices = mesh.triangles.reshape(-1).tolist()
-        faces = np.arange(vertex_count).tolist()
+        vertex_count = len(mesh.faces) * 3
+        normals = np.tile(mesh.face_normals, (1, 3))
+        vertices = vertices[mesh.faces]
+        faces = np.arange(vertex_count, dtype=np.int64)
         colors = np.tile(mesh.visual.face_colors, (1, 3)).reshape((-1, 4))
         color_gl = colors_to_gl(colors, vertex_count)
 
@@ -130,9 +135,9 @@ def mesh_to_vertexlist(mesh, group=None, smooth=True, smooth_threshold=60000):
         vertex_count,  # number of vertices
         GL_TRIANGLES,  # mode
         group,  # group
-        faces,  # indices
-        ("v3f/static", vertices),
-        ("n3f/static", normals),
+        faces.reshape(-1).tolist(),  # indices
+        ("v3f/static", vertices.reshape(-1).tolist()),
+        ("n3f/static", normals.reshape(-1).tolist()),
         color_gl,
     )
 
diff --git a/trimesh/triangles.py b/trimesh/triangles.py
index 8920a625c..542337e84 100644
--- a/trimesh/triangles.py
+++ b/trimesh/triangles.py
@@ -62,6 +62,9 @@ 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
 
 
@@ -86,7 +89,10 @@ def normals(triangles=None, crosses=None):
     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))
+            # 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
     if crosses is None:
         crosses = cross(triangles)
     # unitize the cross product vectors
@@ -375,7 +381,7 @@ def bounds_tree(triangles):
       One node per triangle
     """
     triangles = np.asanyarray(triangles, dtype=np.float64)
-    if not util.is_shape(triangles, (-1, 3, 3)):
+    if not util.is_shape(triangles, (-1, 3, (2, 3))):
         raise ValueError("Triangles must be (n, 3, 3)!")
 
     # the (n,6) interleaved bounding box for every triangle
diff --git a/trimesh/util.py b/trimesh/util.py
index 339dec2df..1507e6e3f 100644
--- a/trimesh/util.py
+++ b/trimesh/util.py
@@ -2035,7 +2035,7 @@ def triangle_fans_to_faces(fans):
         np.transpose([fan[0] * np.ones(len(fan) - 2, dtype=int), fan[1:-1], fan[2:]])
         for fan in fans
     ]
-    return np.concatenate(faces, axis=1)
+    return np.concatenate(faces)
 
 
 def vstack_empty(tup):
@@ -2272,7 +2272,7 @@ def decode_text(text, initial="utf-8"):
     Try to decode byte input as a string.
 
     Tries initial guess (UTF-8) then if that fails it
-    uses chardet to try another guess before failing.
+    uses charset_normalizer to try another guess before failing.
 
     Parameters
     ------------
@@ -2294,12 +2294,11 @@ def decode_text(text, initial="utf-8"):
         text = text.decode(initial)
     except UnicodeDecodeError:
         # detect different file encodings
-        import chardet
+        from charset_normalizer import detect as charset_normalizer_detect
 
         # try to detect the encoding of the file
-        # only look at the first 1000 characters otherwise
-        # for big files chardet looks at everything and is slow
-        detect = chardet.detect(text[:1000])
+        # only look at the first 1000 characters for speed
+        detect = charset_normalizer_detect(text[:1000])
         # warn on files that aren't UTF-8
         log.debug(
             "Data not {}! Trying {} (confidence {})".format(


Test patch:
diff --git a/tests/test_bounds.py b/tests/test_bounds.py
index 1e1680a4a..546c97878 100644
--- a/tests/test_bounds.py
+++ b/tests/test_bounds.py
@@ -20,9 +20,9 @@ def test_obb_mesh_large(self):
         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 < 60, (
+            f"Took {stop - start}s to estimate the oriented bounding box."
+        )
 
     def test_obb_mesh(self):
         """
diff --git a/tests/test_creation.py b/tests/test_creation.py
index c4b20a86f..0567c49d9 100644
--- a/tests/test_creation.py
+++ b/tests/test_creation.py
@@ -324,9 +324,9 @@ def test_revolve(self):
         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 000000000..49b7e6094
--- /dev/null
+++ b/tests/test_exchange.py
@@ -0,0 +1,36 @@
+from shapely.geometry import LineString, MultiLineString
+
+from trimesh.path.exchange.load import load_path
+from trimesh.path.exchange.misc import linestrings_to_path
+
+
+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 2ccd846e2..c75a50602 100644
--- a/tests/test_gltf.py
+++ b/tests/test_gltf.py
@@ -815,7 +815,9 @@ def test_vertex_colors_import(self):
         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_mesh.py b/tests/test_mesh.py
index a7047a821..98a01cf1f 100644
--- a/tests/test_mesh.py
+++ b/tests/test_mesh.py
@@ -8,168 +8,178 @@
     import generic as g
 
 
-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_"):
+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):
                 continue
 
-            g.log.info("Testing %s", file_name)
-            start = {mesh.__hash__(), mesh.__hash__()}
-            assert len(mesh.faces) > 0
-            assert len(mesh.vertices) > 0
+            # nothing in the cache should be writeable
+            if cached.flags["WRITEABLE"]:
+                raise ValueError(f"{name} is writeable!")
 
-            # 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):
+            # only check int, float, and bool
+            if cached.dtype.kind not in "ibf":
                 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)
+            # there should never be NaN values
+            if g.np.isnan(cached).any():
+                raise ValueError("NaN values in %s/%s", file_name, name)
 
-            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!")
+            # fields allowed to have infinite values
+            if name in ["face_adjacency_radius"]:
+                continue
 
-                # only check int, float, and bool
-                if cached.dtype.kind not in "ibf":
-                    continue
+            # make sure everything is finite
+            if not g.np.isfinite(cached).all():
+                raise ValueError("inf values in %s/%s", file_name, name)
 
-                # there should never be NaN values
-                if g.np.isnan(cached).any():
-                    raise ValueError("NaN values in %s/%s", file_name, name)
+        # ...still shouldn't have changed anything
+        assert start == {mesh.__hash__(), mesh.__hash__()}
 
-                # fields allowed to have infinite values
-                if name in ["face_adjacency_radius"]:
-                    continue
+        # 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)))
 
-                # 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__()}
+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
 
-            # 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))
-                )
+    rend = g.trimesh.rendering.mesh_to_vertexlist(m)
+    assert len(rend) > 1
 
 
 if __name__ == "__main__":
     g.trimesh.util.attach_to_log()
-    g.unittest.main()
+    test_mesh_2D()
diff --git a/tests/test_paths.py b/tests/test_paths.py
index 506dc40a0..bffab2ea4 100644
--- a/tests/test_paths.py
+++ b/tests/test_paths.py
@@ -4,319 +4,335 @@
     import generic as g
 
 
-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),
-            )
-            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)
-        )
-
-        # 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)]
+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,
                 )
-        # 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]
+            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)]
             )
+    # make sure this doesn't crash with text entities
+    g.trimesh.rendering.convert_to_vertexlist(p)
 
-            # 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]]
+
+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]
         )
-        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)
+
+        # 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
 
 
 if __name__ == "__main__":