Repository: mikedh/trimesh
Pull request #747 (2020-03-21 00:25:34)

Revolve Linestrings
Moves a lot of the difficult and duplicated indexing logic in creation of various radially symmetric primitives with a single `trimesh.creation.revolve` function. This makes functions like `trimesh.creation.cone`, `trimesh.creation.cylinder`, `trimesh.creation.annulus` MUCH simpler and easier to debug. 

Also includes miscellaneous CI, docs, and utility function changes. 


Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3a945488..ce4aa7bf 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -7,6 +7,7 @@ on:
 
 jobs:
   formatting:
+    name: Check Code Formatting
     runs-on: ubuntu-latest
     steps:
     - uses: actions/checkout@v2
@@ -30,6 +31,7 @@ jobs:
         autopep8 --recursive --aggressive --diff --exit-code tests/
 
   wintests:
+    name: Run Windows Unit Tests
     needs: formatting
     runs-on: ${{ matrix.os }}
     strategy:
@@ -52,6 +54,7 @@ jobs:
       run: python tests/test_inertia.py
 
   tests:
+    name: Run Unit Tests
     needs: formatting
     runs-on: ${{ matrix.os }}
     strategy:
@@ -83,6 +86,7 @@ jobs:
       run: pytest
 
   pypi:
+    name: Release To PyPi
     needs: tests
     runs-on: ubuntu-latest
     steps:
@@ -103,6 +107,7 @@ jobs:
         python setup.py sdist bdist_wheel
         twine upload dist/*
   docs:
+    name: Build Docs
     needs: containers
     runs-on: ubuntu-latest
     steps:
@@ -112,18 +117,13 @@ jobs:
          # build docs in docker image to get working trimesh env for docstrings
          docker pull trimesh/trimesh:latest
          docker run --rm --user 0:0 -v `pwd`:/trimesh trimesh/trimesh:latest python /trimesh/docs/build.py
-    - name: Tag Version
-      id: set_tag
-      run: |
-        export VERSION=$(python -c "exec(open('trimesh/version.py','r').read()); print(__version__)")
-        echo "::set-output name=tag_name::${VERSION}"
     - name: Deploy To Pages
       uses: peaceiris/actions-gh-pages@v3
       with:
-        tag_name: ${{ steps.set_tag.outputs.tag_name }}
         github_token: ${{ secrets.GITHUB_TOKEN }}
         publish_dir: ./docs/html
   containers:
+    name: Build Docker Images
     runs-on: ubuntu-latest
     needs: tests
     steps:
@@ -165,7 +165,7 @@ jobs:
         docker logout
 
   release:
-    name: Create Release
+    name: Create GitHub Release
     needs: tests
     runs-on: ubuntu-latest
     steps:
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 2f499798..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,112 +0,0 @@
-# note that we are in the midst of a migration from travis to github actions
-# we are using miniconda python
-language: generic
-sudo: required
-dist: trusty
-
-matrix:
-  include:
-    - env: PYTHON_VERSION=3.6
-
-before_deploy:
-# extract the current trimesh version from the version file
-- export TM_VERSION=`python -c "exec(open('trimesh/version.py','r').read()); print(__version__)"`
-
-# tag the release and if it's already tagged chill
-- git tag $TM_VERSION || true
-
-deploy:
-
-- provider: releases
-  api_key: $GITHUB_TOKEN
-  skip_cleanup: false
-  # only release the README github will create source
-  # tarballs which are what people would want anyway
-  file: "README.md"
-  on:
-    condition: $PYTHON_VERSION = '3.6'
-    branch: master
-
-before_install:
-- sudo apt-get -qq update
-- sudo apt-get -qq -y install openscad blender meshlab xvfb pandoc libassimp-dev libspatialindex-dev libgeos-dev
-- sudo wget https://github.com/mikedh/v-hacd-1/raw/master/bin/linux/testVHACD --quiet
-  -P /usr/bin
-- sudo chmod +x /usr/bin/testVHACD
-
-
-install:
-- if [[ "$PYTHON_VERSION" == "2.7" ]]; then wget --quiet https://repo.continuum.io/miniconda/Miniconda2-latest-Linux-x86_64.sh
-  -O miniconda.sh; else wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-4.6.14-Linux-x86_64.sh
-  -O miniconda.sh; fi
-- bash miniconda.sh -b -p $HOME/miniconda
-- export PATH="$HOME/miniconda/bin:$PATH"
-- conda config --set always_yes yes --set changeps1 no
-- conda config --add channels conda-forge
-- conda info -a
-- conda create -q -n test-environment python=$PYTHON_VERSION
-- source activate test-environment
-# make sure we parked our car in the right spot
-- python -c "import os,sys;assert '{}.{}'.format(*sys.version_info[:2])==os.environ['PYTHON_VERSION']"
-# lock pip version for python 3.4
-- if [[ "$PYTHON_VERSION" == "3.4" ]]; then conda install pip===10.0.1; fi
-# only install the basic version of the library to make sure
-# trimesh imports and functions with only the minimal install
-- pip install .
-
-script:
-- pip install pytest
-# try simple tests that should succeed with only numpy
-- python -c "import trimesh"
-# grep will print all lines that don't start with a #
-# i.e. filter out comments then print out a list of basic tests to run
-- cd tests; pytest  -p no:warnings `grep -v '^#' basic.list`; cd ..
-# install more testing stuff
-- pip install pytest-cov pyassimp==4.1.3 jsonschema
-# coveralls started failing to install on Python 3.4 so make it optional
-- pip install coveralls || true
-# install style checkers
-- pip install -U autopep8 flake8
-# style checks
-- flake8 trimesh/
-- flake8 example/
-- flake8 tests/
-# will error on non-pep8 formatting
-- autopep8 --recursive --aggressive --diff --exit-code trimesh/
-- autopep8 --recursive --aggressive --diff --exit-code examples/
-- autopep8 --recursive --aggressive --diff --exit-code tests/
-
-# pyembree, scikit-image, python-fcl not available everywhere
-- if [[ "$PYTHON_VERSION" == "3.6" ]]; then conda install pyembree scikit-image; fi;
-- if [[ "$PYTHON_VERSION" == "3.6" ]]; then pip install python-fcl; fi;
-
-# install most deps here
-- pip install -q .[easy]
-
-# xxhash only has linux wheels and is a soft dependancy
-- pip install -q xxhash || true
-# triangle is compiled, sometimes fails 
-# on Python 3.7, and is a soft dependency
-- pip install -q triangle || true
-
-# run main tests
-# -W default means that warnings only prints the
-# first instance of a warning rather than all of them
-- pytest -W default --cov=trimesh tests/
-
-# make sure examples still work by running them all
-- cd examples
-- set -xe; for f in *py*; do python ../tests/notebooks.py ci exec "$f"; done
-- cd ..
-
-# downgrade networkx from 2.x to 1.x to make sure our
-# graph operations work on both versions of their API
-- pip install -q -Iv networkx==1.11
-# fail if it didn't install old networkx for some reason
-- python -c "import networkx; assert networkx.__version__ == '1.11'"
-# will print tons of depreciation warnings we don't care about 
-- pytest -p no:warnings tests/test_graph.py tests/test_scene.py
-
-after_success:
-# only upload test coverage results on python 3.6
-- if [[ "$PYTHON_VERSION" == "3.6" ]]; then coveralls || true; fi;
diff --git a/docs/conf.py b/docs/conf.py
index 8019e180..9007dea9 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -25,11 +25,8 @@ def abspath(rel):
         inspect.getfile(inspect.currentframe())))
     return os.path.abspath(os.path.join(cwd, rel))
 
-
-extensions = [
-    'sphinx.ext.napoleon',
-    'autodocsumm',
-]
+extensions = ['sphinx.ext.napoleon',
+              'autodocsumm']
 
 # Add any paths that contain templates here, relative to this directory.
 templates_path = ['_templates']
@@ -43,7 +40,7 @@ master_doc = 'index'
 
 # General information about the project.
 project = 'trimesh'
-copyright = '2017, Michael Dawson-Haggerty'
+copyright = '2020, Michael Dawson-Haggerty'
 author = 'Michael Dawson-Haggerty'
 
 # The version info for the project you're documenting, acts as replacement for
@@ -78,6 +75,24 @@ todo_include_todos = False
 # The theme to use for HTML and HTML Help pages
 html_theme = 'sphinx_rtd_theme'
 
+# options for rtd-theme
+html_theme_options = {
+    'analytics_id': 'UA-161434837-1',
+    'logo_only': False,
+    'display_version': True,
+    'prev_next_buttons_location': 'bottom',
+    'style_external_links': False,
+    'vcs_pageview_mode': '',
+    'style_nav_header_background': 'white',
+    # toc options
+    'collapse_navigation': True,
+    'sticky_navigation': True,
+    'navigation_depth': 4,
+    'includehidden': True,
+    'titles_only': False,
+    'github_url': 'https://github.com/mikedh/trimesh'
+}
+
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
 # so a file named "default.css" will overwrite the builtin "default.css".
diff --git a/examples/offscreen_render.py b/examples/offscreen_render.py
index 830c28c2..dda3c25d 100644
--- a/examples/offscreen_render.py
+++ b/examples/offscreen_render.py
@@ -14,7 +14,7 @@ if __name__ == '__main__':
     # scene = trimesh.scene.Scene(mesh)
     scene = mesh.scene()
 
-    # a 45 degree homogenous rotation matrix around
+    # a 45 degree homogeneous rotation matrix around
     # the Y axis at the scene centroid
     rotate = trimesh.transformations.rotation_matrix(
         angle=np.radians(10.0),
diff --git a/examples/scan_register.py b/examples/scan_register.py
index efd8a603..bfdb1504 100644
--- a/examples/scan_register.py
+++ b/examples/scan_register.py
@@ -28,7 +28,7 @@ def simulated_brick(face_count, extents, noise, max_iter=10):
     # create the mesh as a simple box
     mesh = trimesh.creation.box(extents=extents)
 
-    # add some systematic error pre- tesselation
+    # add some systematic error pre- tessellation
     mesh.vertices[0] += mesh.vertex_normals[0] + (noise * 2)
 
     # subdivide until we have more faces than we want
@@ -37,7 +37,7 @@ def simulated_brick(face_count, extents, noise, max_iter=10):
             break
         mesh = mesh.subdivide()
 
-    # apply tesselation and random noise
+    # apply tessellation and random noise
     mesh = mesh.permutate.noise(noise)
 
     # randomly rotation with translation
@@ -64,7 +64,7 @@ if __name__ == '__main__':
     # create a "true" mesh
     truth = trimesh.creation.box(extents=extents)
 
-    # (4, 4) float homogenous transform from truth to scan
+    # (4, 4) float homogeneous transform from truth to scan
     # this will do an ICP refinement with initial transforms
     # seeded by the principal components of inertia
     truth_to_scan, cost = truth.register(scan)
diff --git a/examples/viewcallback.py b/examples/viewcallback.py
index 0648c590..80af678c 100644
--- a/examples/viewcallback.py
+++ b/examples/viewcallback.py
@@ -22,7 +22,7 @@ def sinwave(scene):
       Scene containing geometry
 
     """
-    # create an empty homogenous transformation
+    # create an empty homogeneous transformation
     matrix = np.eye(4)
     # set Y as cos of time
     matrix[1][3] = np.cos(time.time()) * 2
diff --git a/trimesh/creation.py b/trimesh/creation.py
index fdc6bc6d..cbe841e6 100644
--- a/trimesh/creation.py
+++ b/trimesh/creation.py
@@ -7,11 +7,11 @@ Create meshes from primitives, or with operations.
 
 from .base import Trimesh
 from .constants import log, tol
-from .triangles import normals
 from .geometry import faces_to_edges, align_vectors
 
 from . import util
 from . import grouping
+from . import triangles
 from . import transformations as tf
 
 import numpy as np
@@ -31,38 +31,133 @@ except BaseException as E:
     load_wkb = exceptions.closure(E)
 
 
-def validate_polygon(obj):
+def revolve(linestring,
+            angle=None,
+            sections=None,
+            transform=None,
+            **kwargs):
     """
-    Make sure an input can be returned as a valid polygon.
+    Revolve a 2D line string around the 2D Y axis, with a result with
+    the 2D Y axis pointing along Z in 3D.
+
+    This function is intended to handle the complexity of indexing
+    and is intended to be used to create all radially symmetric primitives,
+    eventually including cylinders, annular cylinders, capsules, cones,
+    and UV spheres.
+
+    Note that if your linestring is closed, it needs to be counterclockwise
+    if you would like face winding and normals facing outwards.
+
+    Populates vertices, faces, and face normals
 
     Parameters
     -------------
-    obj : shapely.geometry.Polygon, str (wkb), or (n, 2) float
-      Object which might be a polygon
+    linestring : (n, 2) float
+      Lines in 2D which will be revolved
+    angle : None or float
+      Angle in radians to revolve curve by
+    sections : None or int
+      Number of sections result should have
+      If not specified default is 32 per revolution
+    transform : None or (4, 4) float
+      Transform to apply to mesh after construction
+    **kwargs : dict
+      Passed to Trimesh constructor
 
     Returns
-    ------------
-    polygon : shapely.geometry.Polygon
-      Valid polygon object
-
-    Raises
-    -------------
-    ValueError
-      If a valid finite- area polygon isn't available
+    --------------
+    revolved : Trimesh
+      Mesh representing revolved result
     """
-    if isinstance(obj, Polygon):
-        polygon = obj
-    elif util.is_shape(obj, (-1, 2)):
-        polygon = Polygon(obj)
-    elif util.is_string(obj):
-        polygon = load_wkb(obj)
+    linestring = np.asanyarray(linestring, dtype=np.float64)
+
+    # linestring must be ordered 2D points
+    if len(linestring.shape) != 2 or linestring.shape[1] != 2:
+        raise ValueError('linestring must be 2D!')
+
+    if angle is None:
+        # default to closing the revolution
+        angle = np.pi * 2
+        closed = True
     else:
-        raise ValueError('Input not a polygon!')
+        # check passed angle value
+        closed = angle >= ((np.pi * 2) - 1e-8)
+
+    if sections is None:
+        # default to 32 sections for a full revolution
+        sections = int(angle / (np.pi * 2) * 32)
+    # create equally spaced angles
+    theta = np.linspace(0, angle, sections)
+
+    # 2D points around the revolution
+    points = np.column_stack((np.cos(theta), np.sin(theta)))
+
+    # how many points per slice
+    per = len(linestring)
+    # use the 2D X component as radius
+    radius = linestring[:, 0]
+    # use the 2D Y component as the height along revolution
+    height = linestring[:, 1]
+    # a lot of tiling to get our 3D vertices
+    vertices = np.column_stack((
+        np.tile(points, (1, per)).reshape((-1, 2)) *
+        np.tile(radius, len(points)).reshape((-1, 1)),
+        np.tile(height, len(points))))
+
+    if closed:
+        # should be a duplicate set of vertices
+        assert np.allclose(vertices[:per],
+                           vertices[-per:])
+        # chop off duplicate vertices
+        vertices = vertices[:-per]
+
+    if transform is not None:
+        # apply transform to vertices
+        vertices = tf.transform_points(vertices, transform)
+
+    # how many slices of the pie
+    slices = len(theta) - 1
+
+    # start with a quad for every segment
+    # this is a superset which will then be reduced
+    quad = np.array([0, per, 1,
+                     1, per, per + 1])
+    # stack the faces for a single slice of the revolution
+    single = np.tile(quad, per).reshape((-1, 3))
+    # `per` is basically the stride of the vertices
+    single += np.tile(np.arange(per), (2, 1)).T.reshape((-1, 1))
+    # remove any zero-area triangle
+    # this covers many cases without having to think too much
+    single = single[triangles.area(vertices[single]) > tol.merge]
+
+    # how much to offset each slice
+    # note arange multiplied by vertex stride
+    # but tiled by the number of faces we actually have
+    offset = np.tile(np.arange(slices) * per,
+                     (len(single), 1)).T.reshape((-1, 1))
+    # stack a single slice into N slices
+    stacked = np.tile(single.ravel(), slices).reshape((-1, 3))
+
+    if tol.strict:
+        # make sure we didn't screw up stacking operation
+        assert np.allclose(stacked.reshape((-1, single.shape[0], 3)) - single, 0)
+
+    # offset stacked and wrap vertices
+    faces = (stacked + offset) % len(vertices)
 
-    if (not polygon.is_valid or
-            polygon.area < tol.zero):
-        raise ValueError('Polygon is zero- area or invalid!')
-    return polygon
+    # create the mesh from our vertices and faces
+    mesh = Trimesh(vertices=vertices, faces=faces)
+
+    # strict checks run only in unit tests
+    if (tol.strict and
+        np.allclose(radius[[0, -1]], 0.0) or
+            np.allclose(linestring[0], linestring[-1])):
+        # if revolved curve starts and ends with zero radius
+        # it should really be a valid volume, unless the sign
+        # reversed on the input linestring
+        assert mesh.is_volume
+
+    return mesh
 
 
 def extrude_polygon(polygon,
@@ -89,8 +184,10 @@ def extrude_polygon(polygon,
     mesh : trimesh.Trimesh
       Resulting extrusion as watertight body
     """
+    # create a triangulation from the polygon
     vertices, faces = triangulate_polygon(
         polygon, triangle_args=triangle_args, **kwargs)
+    # extrude that triangulation along Z
     mesh = extrude_triangulation(vertices=vertices,
                                  faces=faces,
                                  height=height,
@@ -105,7 +202,7 @@ def sweep_polygon(polygon,
                   **kwargs):
     """
     Extrude a 2D shapely polygon into a 3D mesh along an
-    arbitrary 3D path. Doesn't handle sharp curvature.
+    arbitrary 3D path. Doesn't handle sharp curvature well.
 
 
     Parameters
@@ -216,7 +313,7 @@ def extrude_triangulation(vertices,
                           transform=None,
                           **kwargs):
     """
-    Turn a 2D triangulation into a watertight Trimesh.
+    Extrude a 2D triangulation into a watertight mesh.
 
     Parameters
     ----------
@@ -226,8 +323,8 @@ def extrude_triangulation(vertices,
       Triangle indexes of vertices
     height : float
       Distance to extrude triangulation
-    **kwargs:
-        passed to Trimesh
+    **kwargs : dict
+      Passed to Trimesh constructor
 
     Returns
     ---------
@@ -246,7 +343,7 @@ def extrude_triangulation(vertices,
         raise ValueError('Height must be nonzero!')
 
     # make sure triangulation winding is pointing up
-    normal_test = normals(
+    normal_test = triangles.normals(
         [util.stack_3D(vertices[faces[0]])])[0]
 
     normal_dot = np.dot(normal_test,
@@ -316,13 +413,11 @@ def extrude_triangulation(vertices,
 
 def triangulate_polygon(polygon,
                         triangle_args=None,
-                        engine='auto',
                         **kwargs):
     """
-    Given a shapely polygon create a triangulation using one
-    of the python interfaces to triangle.c:
-    > pip install meshpy
-    > pip install triangle
+    Given a shapely polygon create a triangulation using a
+    python interface to `triangle.c`:
+    > `pip install triangle`
 
     Parameters
     ---------
@@ -330,71 +425,25 @@ def triangulate_polygon(polygon,
         Polygon object to be triangulated
     triangle_args : str or None
         Passed to triangle.triangulate i.e: 'p', 'pq30'
-    engine : str
-        'meshpy', 'triangle', or 'auto'
-    kwargs: passed directly to meshpy.triangle.build:
-            triangle.build(mesh_info,
-                           verbose=False,
-                           refinement_func=None,
-                           attributes=False,
-                           volume_constraints=True,
-                           max_volume=None,
-                           allow_boundary_steiner=True,
-                           allow_volume_steiner=True,
-                           quality_meshing=True,
-                           generate_edges=None,
-                           generate_faces=False,
-                           min_angle=None)
 
     Returns
     --------------
     vertices : (n, 2) float
        Points in space
-    faces :    (n, 3) int
+    faces : (n, 3) int
        Index of vertices that make up triangles
     """
-
-    # set default triangulation if not specified
+    # do the import here for soft requirement
+    from triangle import triangulate
+    # set default triangulation arguments if not specified
     if triangle_args is None:
         triangle_args = 'p'
-
     # turn the polygon in to vertices, segments, and hole points
     arg = _polygon_to_kwargs(polygon)
+    # run the triangulation
+    result = triangulate(arg, triangle_args)
 
-    try:
-        if str(engine).strip() in ['auto', 'triangle']:
-            from triangle import triangulate
-            result = triangulate(arg, triangle_args)
-            return result['vertices'], result['triangles']
-    except ImportError:
-        # no `triangle` so move on to `meshpy`
-        pass
-    except BaseException as E:
-        # if we see an exception log it and move on
-        log.error('failed to triangulate using triangle!',
-                  exc_info=True)
-        # if we are running unit tests exit here and fail
-        if tol.strict:
-            raise E
-
-    # do the import here, as sometimes this import can segfault
-    # which is not catchable with a try/except block
-    from meshpy import triangle
-    # call meshpy.triangle on our cleaned representation
-    info = triangle.MeshInfo()
-    info.set_points(arg['vertices'])
-    info.set_facets(arg['segments'])
-    # not all polygons have holes
-    if 'holes' in arg:
-        info.set_holes(arg['holes'])
-    # build mesh and pass kwargs to triangle
-    mesh = triangle.build(info, **kwargs)
-    # (n, 2) float vertices
-    vertices = np.array(mesh.points, dtype=np.float64)
-    # (m, 3) int faces
-    faces = np.array(mesh.elements, dtype=np.int64)
-
-    return vertices, faces
+    return result['vertices'], result['triangles']
 
 
 def _polygon_to_kwargs(polygon):
@@ -735,9 +784,9 @@ def capsule(height=1.0,
     return capsule
 
 
-def cone(radius=1.0,
-         height=1.0,
-         sections=32,
+def cone(radius,
+         height,
+         sections=None,
          transform=None,
          **kwargs):
     """
@@ -749,87 +798,34 @@ def cone(radius=1.0,
       The radius of the cylinder
     height : float
       The height of the cylinder
-    sections : int
-      How many pie wedges should the cylinder have
-    transform : (4, 4) float
-      Transform to apply
-    **kwargs:
-        passed to Trimesh to create cylinder
+    sections : int or None
+      How many pie wedges per revolution
+    transform : (4, 4) float or None
+      Transform to apply after creation
+    **kwargs : dict
+      Passed to Trimesh constructor
 
     Returns
     ----------
     cone: trimesh.Trimesh
       Resulting mesh of a cone
     """
-    # create a 2D pie out of wedges
-    theta = np.linspace(0, np.pi * 2, sections)
-    vertices = np.column_stack((np.sin(theta),
-                                np.cos(theta))) * radius
-    # the single vertex at the center of the circle
-    # we're overwriting the duplicated start/end vertex
-    vertices[0] = [0, 0]
-
-    # whangle indexes into a triangulation of the pie wedges
-    index = np.arange(1, len(vertices) + 1).reshape((-1, 1))
-    index[-1] = 1
-    faces = np.tile(index, (1, 2)).reshape(-1)[1:-1].reshape((-1, 2))
-    faces = np.column_stack((np.zeros(len(faces), dtype=np.int), faces))
-
-    vertices = np.asanyarray(vertices, dtype=np.float64)
-    height = float(height)
-    faces = np.asanyarray(faces, dtype=np.int64)
-
-    # make sure triangulation winding is pointing up
-    normal_test = normals(
-        [util.stack_3D(vertices[faces[0]])])[0]
-
-    normal_dot = np.dot(normal_test,
-                        [0.0, 0.0, np.sign(height)])[0]
-
-    # make sure the triangulation is aligned with the sign of
-    # the height we've been passed
-    if normal_dot < 0.0:
-        faces = np.fliplr(faces)
-
-    # stack the (n,3) faces into (3*n, 2) edges
-    edges = faces_to_edges(faces)
-    edges_sorted = np.sort(edges, axis=1)
-    # edges which only occur once are on the boundary of the polygon
-    # since the triangulation may have subdivided the boundary of the
-    # shapely polygon, we need to find it again
-    edges_unique = grouping.group_rows(
-        edges_sorted, require_count=1)
-
-    # (n, 2, 2) set of line segments (positions, not references)
-    boundary = vertices[edges[edges_unique]]
-
-    vertical = np.concatenate([boundary, np.zeros((sections - 1, 1, 2))], axis=1)
-    vertical = vertical.reshape(-1, 2)
-    vertical = np.column_stack(
-        (vertical, np.tile([0, 0, height], len(boundary))))
-    vertical_faces = np.arange(len(vertical)).reshape(-1, 3)
-
-    vertices_3D = util.stack_3D(vertices)
-
-    # a sequence of zero- indexed faces, which will then be appended
-    # with offsets to create the final mesh
-    faces_seq = [faces[:, ::-1], vertical_faces]
-    vertices_seq = [vertices_3D, vertical]
-
-    # append sequences into flat nicely indexed arrays
-    vertices, faces = util.append_faces(vertices_seq, faces_seq)
-    cone = Trimesh(vertices=vertices, faces=faces, **kwargs)
-
-    cone.vertices[:, 2] -= height / 4.0
-    if transform is not None:
-        cone.apply_transform(transform)
+    # create the 2D outline of a cone
+    linestring = [[0, 0],
+                  [radius, 0],
+                  [0, height]]
+    # revolve the profile to create a cone
+    cone = revolve(linestring=linestring,
+                   sections=sections,
+                   transform=transform,
+                   **kwargs)
 
     return cone
 
 
-def cylinder(radius=1.0,
-             height=1.0,
-             sections=32,
+def cylinder(radius,
+             height,
+             sections=None,
              segment=None,
              transform=None,
              **kwargs):
@@ -873,45 +869,28 @@ def cylinder(radius=1.0,
         # compound the rotation and translation
         transform = np.dot(translation, rotation)
 
-    # create a 2D pie out of wedges
-    theta = np.linspace(0, np.pi * 2, sections)
-    vertices = np.column_stack((np.sin(theta),
-                                np.cos(theta))) * radius
-    # the single vertex at the center of the circle
-    # we're overwriting the duplicated start/end vertex
-    vertices[0] = [0, 0]
-
-    # whangle indexes into a triangulation of the pie wedges
-    index = np.arange(1, len(vertices) + 1).reshape((-1, 1))
-    index[-1] = 1
-    faces = np.tile(index, (1, 2)).reshape(-1)[1:-1].reshape((-1, 2))
-    faces = np.column_stack((np.zeros(len(faces), dtype=np.int), faces))
-
-    # extrude the 2D triangulation into a Trimesh object
-    cylinder = extrude_triangulation(vertices=vertices,
-                                     faces=faces,
-                                     height=height,
-                                     **kwargs)
-    # the extrusion was along +Z, so move the cylinder
-    # center of mass back to the origin
-    cylinder.vertices[:, 2] -= height * .5
-    if transform is not None:
-        # apply a transform here before any cache stuff is generated
-        # and would have to be dumped after the transform is applied
-        cylinder.apply_transform(transform)
+    half = abs(float(height)) / 2.0
+    # create a profile to revolve
+    linestring = [[0, -half],
+                  [radius, -half],
+                  [radius, half],
+                  [0, half]]
+    # generate cylinder through simple revolution
+    return revolve(linestring=linestring,
+                   sections=sections,
+                   transform=transform)
 
     return cylinder
 
 
-def annulus(r_min=1.0,
-            r_max=2.0,
-            height=1.0,
-            sections=32,
+def annulus(r_min,
+            r_max,
+            height,
+            sections=None,
             transform=None,
             **kwargs):
     """
-    Create a mesh of an annular cylinder along Z,
-    centered at the origin.
+    Create a mesh of an annular cylinder along Z centered at the origin.
 
     Parameters
     ----------
@@ -921,8 +900,10 @@ def annulus(r_min=1.0,
       The outer radius of the annular cylinder
     height : float
       The height of the annular cylinder
-    sections : int
+    sections : int or None
       How many pie wedges should the annular cylinder have
+    transform : (4, 4) float or None
+      Transform to apply to move result from the origin
     **kwargs:
         passed to Trimesh to create annulus
 
@@ -932,49 +913,27 @@ def annulus(r_min=1.0,
       Mesh of annular cylinder
     """
     r_min = abs(float(r_min))
-    r_max = abs(float(r_max))
-    height = float(height)
-    sections = int(sections)
-
     # if center radius is zero this is a cylinder
     if r_min < tol.merge:
         return cylinder(radius=r_max,
                         height=height,
                         sections=sections,
                         transform=transform)
-
-    # create a 2D pie out of wedges
-    theta = np.linspace(0, np.pi * 2, sections)[:-1]
-    unit = np.column_stack((np.sin(theta),
-                            np.cos(theta)))
-    assert len(unit) == sections - 1
-
-    vertices = np.vstack((unit * r_min,
-                          unit * r_max))
-
-    # one flattened triangulated quad covering one slice
-    face = np.array([0, sections - 1, 1,
-                     1, sections - 1, sections])
-
-    # tile one quad into lots of quads
-    faces = (np.tile(face, (sections - 1, 1)) +
-             np.arange(sections - 1).reshape((-1, 1))).reshape((-1, 3))
-
-    # stitch the last and first triangles with correct winding
-    faces[-1] = [sections - 1, 0, sections - 2]
-
-    # extrude the 2D profile into a mesh
-    annulus = extrude_triangulation(vertices=vertices,
-                                    faces=faces,
-                                    height=height,
-                                    **kwargs)
-
-    # move the annulus so the centroid is at the origin
-    annulus.vertices[:, 2] -= height * .5
-    if transform is not None:
-        # apply a transform here before any cache stuff is generated
-        # and would have to be dumped after the transform is applied
-        annulus.apply_transform(transform)
+    r_max = abs(float(r_max))
+    # we're going to center at XY plane so take half the height
+    half = abs(float(height)) / 2.0
+    # create counter-clockwise rectangle
+    linestring = [[r_min, -half],
+                  [r_max, -half],
+                  [r_max, half],
+                  [r_min, half],
+                  [r_min, -half]]
+
+    # revolve the curve
+    annulus = revolve(linestring=linestring,
+                      sections=sections,
+                      transform=transform,
+                      **kwargs)
 
     return annulus
 
diff --git a/trimesh/exceptions.py b/trimesh/exceptions.py
index 68c4379e..da5044ae 100644
--- a/trimesh/exceptions.py
+++ b/trimesh/exceptions.py
@@ -24,7 +24,7 @@ class ExceptionModule(object):
         # this allows isinstance() checks to not re-raise
         if args[0] == '__class__':
             return None.__class__
-        # otherwise return our original exception
+        # otherwise raise our original exception
         raise super(ExceptionModule, self).__getattribute__('exc')
 
 
diff --git a/trimesh/exchange/gltf.py b/trimesh/exchange/gltf.py
index b6ee5a9f..9b8a1a5c 100644
--- a/trimesh/exchange/gltf.py
+++ b/trimesh/exchange/gltf.py
@@ -61,7 +61,7 @@ uint8 = np.dtype("<u1")
 
 def export_gltf(scene,
                 extras=None,
-                include_normals=False):
+                include_normals=None):
     """
     Export a scene object as a GLTF directory.
 
@@ -117,7 +117,7 @@ def export_gltf(scene,
     return files
 
 
-def export_glb(scene, extras=None, include_normals=False):
+def export_glb(scene, extras=None, include_normals=None):
     """
     Export a scene as a binary GLTF (GLB) file.
 
diff --git a/trimesh/exchange/off.py b/trimesh/exchange/off.py
index 884dd850..7c00fbcf 100644
--- a/trimesh/exchange/off.py
+++ b/trimesh/exchange/off.py
@@ -19,10 +19,8 @@ def load_off(file_obj, **kwargs):
       kwargs for Trimesh constructor
     """
     text = file_obj.read()
-    if hasattr(text, 'decode'):
-        text = text.decode('utf-8')
-
-    text = text.lstrip()
+    # will magically survive weird encoding sometimes
+    text = util.decode_text(text).lstrip()
     # split the first key
     header, raw = text.split(None, 1)
 
diff --git a/trimesh/interfaces/generic.py b/trimesh/interfaces/generic.py
index 19d4cd04..40cf4f2c 100644
--- a/trimesh/interfaces/generic.py
+++ b/trimesh/interfaces/generic.py
@@ -18,7 +18,7 @@ class MeshScript:
     def __init__(self,
                  meshes,
                  script,
-                 tmpfile_ext='stl',
+                 exchange='stl',
                  debug=False,
                  **kwargs):
 
@@ -26,7 +26,7 @@ class MeshScript:
         self.kwargs = kwargs
         self.meshes = meshes
         self.script = script
-        self.tmpfile_ext = tmpfile_ext
+        self.exchange = exchange
 
     def __enter__(self):
         # windows has problems with multiple programs using open files so we close
@@ -35,12 +35,12 @@ class MeshScript:
         self.mesh_pre = [
             NamedTemporaryFile(
                 suffix='.{}'.format(
-                    self.tmpfile_ext),
+                    self.exchange),
                 mode='wb',
                 delete=False) for i in self.meshes]
         self.mesh_post = NamedTemporaryFile(
             suffix='.{}'.format(
-                self.tmpfile_ext),
+                self.exchange),
             mode='rb',
             delete=False)
         self.script_out = NamedTemporaryFile(mode='wb',
diff --git a/trimesh/interfaces/scad.py b/trimesh/interfaces/scad.py
index 9ee53dfe..66256957 100644
--- a/trimesh/interfaces/scad.py
+++ b/trimesh/interfaces/scad.py
@@ -47,7 +47,9 @@ def interface_scad(meshes, script, debug=False, **kwargs):
     """
     if not exists:
         raise ValueError('No SCAD available!')
-    with MeshScript(meshes=meshes, script=script, debug=debug) as scad:
+    # OFF is a simple text format that references vertices by-index
+    # making it slightly preferable to STL for this kind of exchange duty
+    with MeshScript(meshes=meshes, script=script, debug=debug, exchange='off') as scad:
         result = scad.run(_scad_executable + ' $SCRIPT -o $MESH_POST')
     return result
 
diff --git a/trimesh/path/util.py b/trimesh/path/util.py
index 732d64aa..77e79811 100644
--- a/trimesh/path/util.py
+++ b/trimesh/path/util.py
@@ -1,31 +1,5 @@
 import numpy as np
-
-
-def is_ccw(points):
-    """
-    Check if connected planar points are counterclockwise.
-
-    Parameters
-    -----------
-    points: (n,2) float, connected points on a plane
-
-    Returns
-    ----------
-    ccw: bool, True if points are counterclockwise
-    """
-    points = np.asanyarray(points, dtype=np.float64)
-
-    if (len(points.shape) != 2 or
-            points.shape[1] != 2):
-        raise ValueError('CCW is only defined for 2D')
-    xd = np.diff(points[:, 0])
-    yd = np.column_stack((
-        points[:, 1],
-        points[:, 1])).reshape(-1)[1:-1].reshape((-1, 2)).sum(axis=1)
-    area = np.sum(xd * yd) * .5
-    ccw = area < 0
-
-    return ccw
+from ..util import is_ccw  # NOQA
 
 
 def concatenate(paths):
@@ -34,11 +8,13 @@ def concatenate(paths):
 
     Parameters
     -------------
-    paths: list of Path, Path2D, or Path3D objects
+    paths : (n,) Path
+      Path objects to concatenate
 
     Returns
     -------------
-    concat: Path, Path2D, or Path3D object
+    concat : Path, Path2D, or Path3D
+      Concatenated result
     """
     # if only one path object just return copy
     if len(paths) == 1:
diff --git a/trimesh/util.py b/trimesh/util.py
index 7dc4f1a2..46920f75 100644
--- a/trimesh/util.py
+++ b/trimesh/util.py
@@ -1222,6 +1222,49 @@ def decode_keys(store, encoding='utf-8'):
     return store
 
 
+def comment_strip(text, starts_with='#', new_line='\n'):
+    """
+    Strip comments from a text block.
+
+    Parameters
+    -----------
+    text : str
+      Text to remove comments from
+    starts_with : str
+      Charecter or substring that starts a comment
+    new_line : str
+      Charecter or substring that ends a comment
+
+    Returns
+    -----------
+    stripped : str
+      Text with comments stripped
+    """
+    # if not contained exit immediately
+    if starts_with not in text:
+        return text
+
+    # start by splitting into chunks by the comment indicator
+    split = (text + new_line).split(starts_with)
+
+    # special case files that start with a comment
+    if text.startswith(starts_with):
+        lead = ''
+    else:
+        lead = split[0]
+
+    # take each comment up until the newline
+    removed = [i.split(new_line, 1) for i in split]
+    # add the leading string back on
+    result = lead + new_line.join(
+        i[1] for i in removed
+        if len(i) > 1 and len(i[1]) > 0)
+    # strip leading and trailing whitespace
+    result = result.strip()
+
+    return result
+
+
 def encoded_to_array(encoded):
     """
     Turn a dictionary with base64 encoded strings back into a numpy array.
@@ -2230,3 +2273,32 @@ def to_ascii(text):
         return text.decode('ascii', errors='ignore')
     # otherwise just wrap as a string
     return str(text)
+
+
+def is_ccw(points):
+    """
+    Check if connected 2D points are counterclockwise.
+
+    Parameters
+    -----------
+    points : (n, 2) float
+      Connected points on a plane
+
+    Returns
+    ----------
+    ccw : bool
+      True if points are counter-clockwise
+    """
+    points = np.asanyarray(points, dtype=np.float64)
+
+    if (len(points.shape) != 2 or points.shape[1] != 2):
+        raise ValueError('CCW is only defined for 2D')
+    xd = np.diff(points[:, 0])
+    # sum along axis=1 with a dot product
+    yd = np.dot(np.column_stack((
+        points[:, 1],
+        points[:, 1])).reshape(-1)[1:-1].reshape((-1, 2)), [1, 1])
+    area = np.sum(xd * yd) * .5
+    ccw = area < 0
+
+    return ccw
diff --git a/trimesh/version.py b/trimesh/version.py
index 2d70261d..6332525b 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.6.11'
+__version__ = '3.6.12'
diff --git a/trimesh/viewer/notebook.py b/trimesh/viewer/notebook.py
index 1fe47f32..770da2f6 100644
--- a/trimesh/viewer/notebook.py
+++ b/trimesh/viewer/notebook.py
@@ -33,7 +33,6 @@ def scene_to_html(scene):
     data = scene.export(file_type='glb')
     # encode as base64 string
     encoded = base64.b64encode(data).decode('utf-8')
-
     # replace keyword with our scene data
     result = base.replace('$B64GLTF', encoded)
 
@@ -67,10 +66,11 @@ def scene_to_notebook(scene, height=500, **kwargs):
     # embed this puppy as the srcdoc attr of an IFframe
     # I tried this a dozen ways and this is the only one that works
     # display.IFrame/display.Javascript really, really don't work
+    # note trailing space to avoid IPython's pointless hardcoded warning
     embedded = display.HTML(
         '<iframe srcdoc="{srcdoc}" '
         'width="100%" height="{height}px" '
-        'style="border:none;"></iframe>'.format(
+        'style="border:none;"></iframe> '.format(
             srcdoc=srcdoc,
             height=height))
     return embedded


Test patch:
diff --git a/tests/test_arc.py b/tests/test_arc.py
index e6c40aca..f9150bab 100644
--- a/tests/test_arc.py
+++ b/tests/test_arc.py
@@ -77,8 +77,8 @@ class ArcTests(g.unittest.TestCase):
         Test a Path2D object containing polygons nested in
         the interiors of other polygons.
         """
-        inner = g.trimesh.creation.annulus(r_min=.5, r_max=.6)
-        outer = g.trimesh.creation.annulus(r_min=.9, r_max=1.0)
+        inner = g.trimesh.creation.annulus(r_min=.5, r_max=0.6, height=1.0)
+        outer = g.trimesh.creation.annulus(r_min=.9, r_max=1.0, height=1.0)
         m = inner + outer
 
         s = m.section(plane_normal=[0, 0, 1],
diff --git a/tests/test_creation.py b/tests/test_creation.py
index 44c831bc..c317dc49 100644
--- a/tests/test_creation.py
+++ b/tests/test_creation.py
@@ -7,45 +7,24 @@ except BaseException:
 class CreationTest(g.unittest.TestCase):
 
     def setUp(self):
-        # we support two interfaces to triangle:
-        # pip install meshpy
-        # pip install triangle
         engines = []
-        try:
-            import meshpy  # NOQA
-            engines.append('meshpy')
-        except ImportError:
-            g.log.error("no meshpy: skipping")
-
         try:
             from triangle import triangulate  # NOQA
             engines.append('triangle')
         except ImportError:
             g.log.error('no triangle: skipping')
-
         self.engines = engines
 
     def test_cone(self):
-        # tolerance for cone
-        atol = 0.03
-
-        c = g.trimesh.creation.cone()
-        assert c.is_volume
-        assert c.body_count == 1
-
         c = g.trimesh.creation.cone(radius=0.5, height=1.0)
         assert c.is_volume
         assert c.body_count == 1
-        assert g.np.allclose(c.extents, 1.0, atol=atol)
+        assert g.np.allclose(c.extents, 1.0, atol=0.03)
 
     def test_cylinder(self):
         # tolerance for cylinders
         atol = 0.03
 
-        c = g.trimesh.creation.cylinder()
-        assert c.is_volume
-        assert c.body_count == 1
-
         c = g.trimesh.creation.cylinder(radius=0.5, height=1.0)
         assert c.is_volume
         assert c.body_count == 1
diff --git a/tests/test_util.py b/tests/test_util.py
index 43c187d4..9723743a 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -306,6 +306,34 @@ class UniqueTests(unittest.TestCase):
             assert np.all(truth_counts == counts)
 
 
+class CommentTests(unittest.TestCase):
+
+    def test_comment(self):
+        # test our comment stripping logic
+        f = g.trimesh.util.comment_strip
+
+        text = 'hey whats up'
+        assert f(text) == text
+
+        text = '#hey whats up'
+        assert f(text) == ''
+
+        text = '   # hey whats up '
+        assert f(text) == ''
+
+        text = '# naahah\nhey whats up'
+        assert f(text) == 'hey whats up'
+
+        text = '#naahah\nhey whats up\nhi'
+        assert f(text) == 'hey whats up\nhi'
+
+        text = '#naahah\nhey whats up\n hi'
+        assert f(text) == 'hey whats up\n hi'
+
+        text = '#naahah\nhey whats up\n hi#'
+        assert f(text) == 'hey whats up\n hi'
+
+
 if __name__ == '__main__':
     trimesh.util.attach_to_log()
     unittest.main()