Repository: mikedh/trimesh
Pull request #730 (2020-03-05 00:26:46)

[WIP] Unify CI To GitHub Actions
Begin to switch from a mix of CI providers to Github Actions, which tests on Windows/Linux/OSX and major Python versions.

This may be a longer-term project to cajole a fairly gigantic test matrix to pass, so I will probably merge the incremental improvement which keeps travis for now. 

- [x] Deploys docker images to `trimesh/trimesh`, `mikedh/trimesh`, and an AWS ECR repo, with each image tagged by trimesh version, short git commit SHA, and `latest`
- [ ] Deploys to PyPi after successful builds
- [ ] Deploys docs to GitHub pages after successful builds 
- [ ] Tests actually succeed
- [ ] Minimal `pytest` succeeds. 
- [x] removes CircleCI (previously building docker images)
- [x] removes appveyor (windows tests)
- [ ] removes Travis (previously doing linux builds, PyPi deploy, github pages deploy) 


Patch:
diff --git a/.appveyor.yml b/.appveyor.yml
deleted file mode 100644
index 3ea56e69..00000000
--- a/.appveyor.yml
+++ /dev/null
@@ -1,28 +0,0 @@
-build: false
-image: Previous Visual Studio 2015
-
-environment:
-  matrix:
-    - PYTHON_VERSION: 2.7
-      MINICONDA: C:\Miniconda
-    - PYTHON_VERSION: 3.5
-      MINICONDA: C:\Miniconda3
-    - PYTHON_VERSION: 3.6
-      MINICONDA: C:\Miniconda3
-    - PYTHON_VERSION: 3.7
-      MINICONDA: C:\Miniconda3
-
-init:
-  - "ECHO %PYTHON_VERSION% %MINICONDA%"
-
-install:
-  - "set PATH=%MINICONDA%;%MINICONDA%\\Scripts;C:\\blender-2.78a-windows64;%PATH%"
-  - conda config --set always_yes yes --set changeps1 no
-  - conda info -a
-  - "conda create -q -n test-environment python=%PYTHON_VERSION%"
-  - activate test-environment
-  - conda install -c conda-forge shapely rtree
-  - pip install .[easy,test]
-
-test_script:
-  - pytest tests
\ No newline at end of file
diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index 0c9a8d36..00000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,19 +0,0 @@
-# Python CircleCI 2.0 configuration file
-version: 2
-jobs:
-  build:
-    working_directory: ~/repo
-    docker:
-      - image: docker:17.05.0-ce-git
-    steps:
-      - checkout
-      - setup_remote_docker
-      - run:
-          name: Build Docker Image
-          command: |
-            docker build . -t "mikedh/trimesh"
-      - deploy:
-          name: Push To DockerHub
-          command: |
-            docker login --username ${DOCKER_USER} --password ${DOCKER_PASS}
-            docker push "mikedh/trimesh"
diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml
new file mode 100644
index 00000000..36082a37
--- /dev/null
+++ b/.github/workflows/pythonpublish.yml
@@ -0,0 +1,170 @@
+name: Test And Publish
+
+on:
+  push:
+    branches:
+      - actions
+jobs:
+  formatting:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v2
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v1
+      with:
+        python-version: 3.8
+    - name: Install Formatting
+      run: |
+        python -m pip install --upgrade pip
+        pip install autopep8 flake8
+    - name: Check Formatting
+      run: |
+        flake8 tests
+        flake8 trimesh
+        flake8 examples
+        flake8 setup.py
+        autopep8 --recursive --aggressive --diff --exit-code trimesh/
+        autopep8 --recursive --aggressive --diff --exit-code examples/
+        autopep8 --recursive --aggressive --diff --exit-code tests/
+
+  wintests:
+    needs: formatting
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        python-version: [2.7, 3.5, 3.6, 3.7, 3.8]
+        os: [windows-latest]
+    steps:
+    - uses: actions/checkout@v2
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v1
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install Windows
+      if: matrix.os == 'windows-latest'
+      run: brew install spatialindex geos
+    - name: Install Pytest
+      run: |
+        python -m pip install --upgrade pip
+        pip install pytest pytest-cov setuptools
+    - name: Install Trimesh
+      run: pip install .[easy]
+    - name: Run Pytest
+      run: pytest
+
+
+  tests:
+    needs: formatting
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        python-version: [2.7, 3.5, 3.6, 3.7, 3.8]
+        os: [ubuntu-latest, macos-latest]
+    steps:
+    - uses: actions/checkout@v2
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v1
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install APT On Linux
+      if: matrix.os == 'ubuntu-latest'
+      run: |
+        sudo apt-get update -qq -y
+        sudo apt-get install -qq -y libspatialindex-dev libgeos-dev
+    - name: Install Brew On Mac
+      if: matrix.os == 'macos-latest'
+      run: brew install spatialindex geos
+    - name: Install Pytest
+      run: |
+        python -m pip install --upgrade pip
+        pip install pytest pytest-cov setuptools
+        pip debug install -vv shapely
+    - name: Install Trimesh
+      run: pip install .[easy]
+    - name: Run Pytest
+      run: pytest
+
+
+  pypi:
+    needs: tests
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v2
+    - name: Set up Python
+      uses: actions/setup-python@v1
+      with:
+        python-version: '3.x'
+    - name: Install publishing dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install setuptools wheel twine
+    - name: Build and publish
+      env:
+        TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
+        TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
+      run: |
+        python setup.py sdist bdist_wheel
+        twine upload dist/*
+  docs:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v2
+    - name: Set up Python
+      uses: actions/setup-python@v1
+      with:
+        python-version: '3.x'
+    - name: Install Pandoc
+      run: |
+        sudo apt-get update -qq -y
+        sudo apt-get install -qq -y --no-install-recommends pandoc
+    - name: Install From Pip
+      run: |
+        python -m pip install --upgrade pip
+        pip install -r docs/requirements.txt
+    - name: Build Docs
+      run: python docs/build.py
+    - name: Deploy To Pages
+      uses: peaceiris/actions-gh-pages@v3
+      with:
+        github_token: ${{ secrets.GITHUB_TOKEN }}
+        publish_dir: ./docs/html
+
+  containers:
+    runs-on: ubuntu-latest
+    steps:
+    - name: Configure AWS Credentials
+      uses: aws-actions/configure-aws-credentials@v1
+      with:
+        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+        aws-region: us-east-2
+    - name: Login to Amazon ECR
+      id: login-ecr
+      uses: aws-actions/amazon-ecr-login@v1
+    - name: Login to Docker Hub
+      run: echo ${{ secrets.DH_PASS }} | docker login --username mikedh --password-stdin
+    - name: Checkout trimesh
+      uses: actions/checkout@v2
+    - name: Build/Push to DockerHub and AWS ECR
+      env:
+        ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
+        ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }}
+        GIT_SHA: ${{ github.sha }}
+      run: |
+        # get the version of trimesh with python
+        export VERSION=$(python -c "exec(open('trimesh/version.py','r').read()); print(__version__)")
+        # tag the image with the short git sha
+        export GIT_SHORT=${GIT_SHA::7}
+        # put a whole bunch of tags on image:
+        # tag with version, git sha, and latest
+        # push to DockerHub and AWS ECR
+        export TAGS="trimesh/trimesh:latest; trimesh/trimesh:$VERSION; trimesh/trimesh:$GIT_SHORT; $ECR_REGISTRY/$ECR_REPOSITORY:$VERSION; $ECR_REGISTRY/$ECR_REPOSITORY:latest; $ECR_REGISTRY/$ECR_REPOSITORY:$GIT_SHORT"
+        # build image tagged with all our many tags
+        docker build -t `echo "${TAGS//;/$' -t '}"` .
+        # push every tag to remote registry
+        for i in ${TAGS//;/ }; do docker push $i; done;
+    - name: Logout of registries
+      if: always()
+      run: |
+        docker logout ${{ steps.login-ecr.outputs.registry }}
+        docker logout
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index bdd932ab..abfe7812 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,31 +1,31 @@
 FROM debian:buster-slim
-MAINTAINER Michael Dawson-Haggerty <mikedh@kerfed.com>
+LABEL maintainer="mikedh@kerfed.com"
 
 COPY docker/builds/apt.bash /tmp/
 RUN bash /tmp/apt.bash
 
-# build draco and download vhacd
+# copy compile recipies to build draco and download vhacd
 COPY docker/builds/draco.bash /tmp/
 COPY docker/builds/vhacd.bash /tmp/
-COPY docker/builds/builds.bash /tmp/
-RUN bash /tmp/builds.bash
+RUN bash /tmp/draco.bash && bash /tmp/vhacd.bash
 
 # XVFB in background if you start supervisor
 COPY docker/config/xvfb.supervisord.conf /etc/supervisor/conf.d/
 
-# copy local trimesh for install and tests
-COPY . /tmp/trimesh
-
 # switch out of root
 RUN useradd -m -s /bin/bash user
-# make sure user owns all of tmp
-RUN chown -R user:user /tmp
-# switch to non-root
+
+# copy local trimesh for install and tests
+COPY --chown=user:user . /tmp/trimesh
+
+# switch to user
 USER user
 
 # install a conda env and trimesh
 RUN bash /tmp/trimesh/docker/builds/conda.bash
 
+USER user
+
 # add user python to path 
 ENV PATH="/home/user/conda/bin:$PATH"
 
diff --git a/README.md b/README.md
index 026db14e..0ea7f586 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
 [![trimesh](https://trimsh.org/images/logotype-a.svg)](http://trimsh.org)
 
 -----------
-[![Build Status](https://travis-ci.org/mikedh/trimesh.svg?branch=master)](https://travis-ci.org/mikedh/trimesh) [![Build status](https://ci.appveyor.com/api/projects/status/j8h3luwvst1tkghl/branch/master?svg=true)](https://ci.appveyor.com/project/mikedh/trimesh/branch/master) [![Coverage Status](https://coveralls.io/repos/github/mikedh/trimesh/badge.svg)](https://coveralls.io/github/mikedh/trimesh) [![PyPI version](https://badge.fury.io/py/trimesh.svg)](https://badge.fury.io/py/trimesh) [![CircleCI](https://circleci.com/gh/mikedh/trimesh/tree/master.svg?style=svg)](https://circleci.com/gh/mikedh/trimesh/tree/master) [![Join the chat at https://gitter.im/trimsh/Lobby](https://badges.gitter.im/trimsh/Lobby.svg)](https://gitter.im/trimsh/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
+[![Build Status](https://travis-ci.org/mikedh/trimesh.svg?branch=master)](https://travis-ci.org/mikedh/trimesh) [![Coverage Status](https://coveralls.io/repos/github/mikedh/trimesh/badge.svg)](https://coveralls.io/github/mikedh/trimesh) [![PyPI version](https://badge.fury.io/py/trimesh.svg)](https://badge.fury.io/py/trimesh) [![CircleCI](https://circleci.com/gh/mikedh/trimesh/tree/master.svg?style=svg)](https://circleci.com/gh/mikedh/trimesh/tree/master) [![Join the chat at https://gitter.im/trimsh/Lobby](https://badges.gitter.im/trimsh/Lobby.svg)](https://gitter.im/trimsh/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
 
 
 
@@ -216,9 +216,9 @@ A question that comes up pretty frequently is [how to cite the library.](https:/
 
 ## Containers
    
-If you want to deploy something in a container that uses trimesh, automated `debian:buster-slim` based builds with trimesh and dependencies are available on Docker Hub:
+If you want to deploy something in a container that uses trimesh, automated `debian:buster-slim` based builds with trimesh and dependencies are available on [Docker Hub](https://hub.docker.com/repository/docker/trimesh/trimesh) with image tags for `latest`, git short hash for the commit in master (i.e. `trimesh/trimesh:0c1298d`), and version (i.e. `trimesh/trimesh:3.5.27`):
 
-`docker pull mikedh/trimesh`
+`docker pull trimesh/trimesh`
 
 [Here's an example](https://github.com/mikedh/trimesh/tree/master/examples/dockerRender) of how to render meshes using LLVMpipe and XVFB inside a container.
 
diff --git a/docker/builds/apt.bash b/docker/builds/apt.bash
index df4f0c06..e304774e 100644
--- a/docker/builds/apt.bash
+++ b/docker/builds/apt.bash
@@ -1,10 +1,12 @@
 set -xe
-apt-get update -qq
-apt-get upgrade -y -qq --no-install-recommends
-apt-get install -y -qq --no-install-recommends wget bzip2 supervisor \
+
+apt-get update -y -qq
+apt-get install -y -qq --no-install-recommends \
+	wget bzip2 supervisor \
 	libgl1-mesa-glx libgl1-mesa-dri xvfb xauth \
 	libassimp-dev ca-certificates zstd unzip \
-	freeglut3-dev
+	freeglut3-dev git sudo \
+	build-essential g++ gcc cmake
 
 # remove garbage
 apt-get clean -qq
diff --git a/docker/builds/builds.bash b/docker/builds/builds.bash
deleted file mode 100644
index 79a71c08..00000000
--- a/docker/builds/builds.bash
+++ /dev/null
@@ -1,18 +0,0 @@
-# exit if anything fails
-set -xe
-
-apt-get update
-PACKAGES_BUILD="git ca-certificates build-essential g++ gcc cmake"
-apt-get -y --no-install-recommends install $PACKAGES_BUILD
-
-# install draco, google's mesh compression utility
-bash "$(dirname $0)/draco.bash"
-# install VHACD, a mesh decomposition utility
-bash "$(dirname $0)/vhacd.bash"
-
-# remove garbage
-apt-get clean
-rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
-
-
-
diff --git a/docker/builds/conda.bash b/docker/builds/conda.bash
index a584b6cf..75e62805 100644
--- a/docker/builds/conda.bash
+++ b/docker/builds/conda.bash
@@ -1,17 +1,17 @@
 set -xe
 
-cd
 # get versioned miniconda installer via HTTPS
-wget https://repo.anaconda.com/miniconda/Miniconda3-4.6.14-Linux-x86_64.sh --quiet -O miniconda.sh
+wget https://repo.anaconda.com/miniconda/Miniconda3-4.7.12.1-Linux-x86_64.sh --quiet -O /tmp/miniconda.sh
+
 # check hash of file
-echo "0d6b23895a91294a4924bd685a3a1f48e35a17970a073cd2f684ffe2c31fc4be  miniconda.sh" | sha256sum --check
+echo "bfe34e1fa28d6d75a7ad05fd02fa5472275673d5f5621b77380898dee1be15d2 /tmp/miniconda.sh" | sha256sum --check
 # run miniconda install
-bash miniconda.sh -b -p ~/conda
+bash /tmp/miniconda.sh -b -p /home/user/conda
 # delete installer
-rm miniconda.sh
+rm /tmp/miniconda.sh
 
 # make sure conda base
-export PATH="~/conda/bin:$PATH"
+export PATH="/home/user/conda/bin:$PATH"
 conda config --set always_yes yes --set changeps1 no
 # add conda-forge as remote channel
 conda config --add channels conda-forge
@@ -21,12 +21,10 @@ conda config --add channels conda-forge
 # conda/numpy is compiled with intel's MKL
 conda install pyembree rtree
 
-# install trimesh from the repo
-cd /tmp/trimesh
 # include all soft dependencies
-pip install .[all,test] pyassimp==4.1.3
+pip install --no-cache-dir /tmp/trimesh[all,test] pyassimp==4.1.3
 
 # remove archives
-conda clean --all
+conda clean --all -f -y
 # remove pip cache and temp files
-rm -rf ~/.cache/pip || true
+rm -rf ~/.cache || true
diff --git a/docker/builds/draco.bash b/docker/builds/draco.bash
index ded98bcd..f492c4d8 100644
--- a/docker/builds/draco.bash
+++ b/docker/builds/draco.bash
@@ -21,5 +21,10 @@ cd /tmp/draco_build
 cmake /tmp/draco-1.3.5
 make
 # move executables to our PATH
-mv draco_encoder /usr/bin
-mv draco_decoder /usr/bin
+mv /tmp/draco_build/draco_encoder /usr/bin
+mv /tmp/draco_build/draco_decoder /usr/bin
+cd
+
+rm -rf /tmp/draco_build
+rm -rf /tmp/draco-1.3.5
+rm -f /tmp/draco.zip
diff --git a/setup.py b/setup.py
index 3b3f7854..a76d4495 100644
--- a/setup.py
+++ b/setup.py
@@ -41,6 +41,7 @@ requirements_easy = set([
     'requests',  # do network requests
     'xxhash',    # hash ndarrays faster than built-in MD5/CRC
     'setuptools',  # do setuptools stuff
+    'jsonschema',  # validate JSON schemas like GLTF
     'pycollada',   # parse collada/dae/zae files
     'chardet',     # figure out if someone used UTF-16
     'colorlog'])   # log in pretty colors
@@ -53,7 +54,6 @@ requirements_all = requirements_easy.union([
     'python-fcl',    # do fast 3D collision queries
     'psutil',        # figure out how much memory we have
     'glooey',        # make GUI applications with 3D stuff
-    'jsonschema',    # validate JSON schemas like GLTF
     'meshio',        # load a number of additional mesh formats; Python 3.5+
     'scikit-image'])  # marching cubes and other nice stuff
 
diff --git a/trimesh/base.py b/trimesh/base.py
index d1965737..6a713d82 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -286,7 +286,7 @@ class Trimesh(Geometry):
         faces : (n, 3) int
           Representing triangles which reference self.vertices
         """
-        return self._data.get('faces', np.empty(shape=(0, 3), dtype=int))
+        return self._data.get('faces', np.empty(shape=(0, 3), dtype=np.int64))
 
     @faces.setter
     def faces(self, values):
@@ -425,7 +425,7 @@ class Trimesh(Geometry):
         vertices : (n, 3) float
           Points in cartesian space referenced by self.faces
         """
-        return self._data['vertices']
+        return self._data.get('vertices', np.empty(shape=(0, 3), dtype=np.float64))
 
     @vertices.setter
     def vertices(self, values):
diff --git a/trimesh/exceptions.py b/trimesh/exceptions.py
index 657dd8e6..68c4379e 100644
--- a/trimesh/exceptions.py
+++ b/trimesh/exceptions.py
@@ -20,6 +20,11 @@ class ExceptionModule(object):
         self.exc = exc
 
     def __getattribute__(self, *args, **kwargs):
+        # if it's asking for our class type return None
+        # this allows isinstance() checks to not re-raise
+        if args[0] == '__class__':
+            return None.__class__
+        # otherwise return our original exception
         raise super(ExceptionModule, self).__getattribute__('exc')
 
 
diff --git a/trimesh/exchange/misc.py b/trimesh/exchange/misc.py
index 2381d993..1e7551fb 100644
--- a/trimesh/exchange/misc.py
+++ b/trimesh/exchange/misc.py
@@ -123,8 +123,18 @@ def load_meshio(file_obj, file_type=None, **kwargs):
     mesh = meshio.read(file_obj.name, file_format=file_format)
 
     # save data as kwargs for a trimesh.Trimesh
-    result = {'vertices': mesh.points,
-              'faces': mesh.get_cells_type("triangle")}
+    result = {}
+    # pass kwargs to mesh constructor
+    result.update(kwargs)
+    # add vertices
+    result['vertices'] = mesh.points
+    try:
+        # add faces
+        result['faces'] = mesh.get_cells_type("triangle")
+    except BaseException:
+        util.log.warning('unable to get faces', exc_info=True)
+        result['faces'] = []
+
     return result
 
 
diff --git a/trimesh/exchange/obj.py b/trimesh/exchange/obj.py
index 674d81db..b5553bed 100644
--- a/trimesh/exchange/obj.py
+++ b/trimesh/exchange/obj.py
@@ -1,13 +1,17 @@
-import collections
 import numpy as np
+from collections import deque, defaultdict
 
 try:
+    # `pip install pillow`
+    # optional: used for textured meshes
     import PIL.Image as Image
-except ImportError:
-    pass
+except BaseException as E:
+    # if someone tries to use Image re-raise
+    # the import error so they can debug easily
+    from ..exceptions import ExceptionModule
+    Image = ExceptionModule(E)
 
 from .. import util
-
 from ..visual.color import to_float
 from ..visual.texture import unmerge_faces, TextureVisuals
 from ..visual.material import SimpleMaterial
@@ -526,7 +530,7 @@ def _parse_vertices(text):
     per_row = {k: len(v[0].split()) for k, v in data.items()}
 
     # convert data values into numpy arrays
-    result = collections.defaultdict(lambda: None)
+    result = defaultdict(lambda: None)
     for k, value in data.items():
         # use joining and fromstring to get as numpy array
         array = np.fromstring(
@@ -599,7 +603,7 @@ def _group_by_material(face_tuples):
     """
 
     # store the chunks grouped by material
-    grouped = collections.defaultdict(lambda: ['', '', []])
+    grouped = defaultdict(lambda: ['', '', []])
     # loop through existring
     for material, obj, chunk in face_tuples:
         grouped[material][0] = material
@@ -702,9 +706,11 @@ def _preprocess_faces(text, split_object=False):
 def export_obj(mesh,
                include_normals=True,
                include_color=True,
-               include_texture=False):
+               include_texture=False,
+               digits=8):
     """
-    Export a mesh as a Wavefront OBJ file
+    Export a mesh as a Wavefront OBJ file.
+    TODO: scenes with textured meshes
 
     Parameters
     -----------
@@ -719,6 +725,9 @@ def export_obj(mesh,
       False by default as it will change the return
       values to include files that must be saved in the
       same location as the exported mesh.
+    digits : int
+      Number of digits to include for floating point
+
 
     Returns
     -----------
@@ -735,77 +744,94 @@ def export_obj(mesh,
                     ('v', 'vn'): '{}//{}',
                     ('v', 'vt'): '{}/{}',
                     ('v', 'vn', 'vt'): '{}/{}/{}'}
-    # we are going to reference face_formats with this
-    face_type = ['v']
-
-    # OBJ includes vertex color as RGB elements on the same line
-    if include_color and mesh.visual.kind in ['vertex', 'face']:
-        # create a stacked blob with position and color
-        v_blob = np.column_stack((
-            mesh.vertices,
-            to_float(mesh.visual.vertex_colors[:, :3])))
+
+    # check the input
+    if util.is_instance_named(mesh, 'Trimesh'):
+        meshes = [mesh]
+    elif util.is_instance_named(mesh, 'Scene'):
+        meshes = mesh.dump()
     else:
-        # otherwise just export vertices
-        v_blob = mesh.vertices
+        raise ValueError('must be Trimesh or Scene!')
 
-    # add the first vertex key and convert the array
-    export = collections.deque(
-        ['v ' + util.array_to_string(
-            v_blob,
-            col_delim=' ',
-            row_delim='\nv ',
-            digits=8) + '\n'])
-
-    # only include vertex normals if they're already stored
-    if include_normals and 'vertex_normals' in mesh._cache:
-        # if vertex normals are stored in cache export them
-        face_type.append('vn')
-        export.append('vn ' + util.array_to_string(
-            mesh.vertex_normals,
-            col_delim=' ',
-            row_delim='\nvn ',
-            digits=8) + '\n')
-
-    tex_data = None
-    if include_texture and hasattr(mesh.visual, 'uv'):
-        # if vertex texture exists and is the right shape
-        face_type.append('vt')
-        # add the uv coordinates
-        export.append('vt ' + util.array_to_string(
-            mesh.visual.uv,
+    objects = deque([])
+
+    counts = {'v': 0, 'vn': 0, 'vt': 0}
+
+    for mesh in meshes:
+        # we are going to reference face_formats with this
+        face_type = ['v']
+        # OBJ includes vertex color as RGB elements on the same line
+        if include_color and mesh.visual.kind in ['vertex', 'face']:
+            # create a stacked blob with position and color
+            v_blob = np.column_stack((
+                mesh.vertices,
+                to_float(mesh.visual.vertex_colors[:, :3])))
+        else:
+            # otherwise just export vertices
+            v_blob = mesh.vertices
+
+            # add the first vertex key and convert the array
+        # add the vertices
+        export = deque(
+            ['v ' + util.array_to_string(
+                v_blob,
+                col_delim=' ',
+                row_delim='\nv ',
+                digits=digits)])
+        # only include vertex normals if they're already stored
+        if include_normals and 'vertex_normals' in mesh._cache.cache:
+            # if vertex normals are stored in cache export them
+            face_type.append('vn')
+            export.append('vn ' + util.array_to_string(
+                mesh.vertex_normals,
+                col_delim=' ',
+                row_delim='\nvn ',
+                digits=digits))
+
+        tex_data = None
+        if include_texture and hasattr(mesh.visual, 'uv'):
+            # if vertex texture exists and is the right shape
+            face_type.append('vt')
+            # add the uv coordinates
+            export.append('vt ' + util.array_to_string(
+                mesh.visual.uv,
+                col_delim=' ',
+                row_delim='\nvt ',
+                digits=digits))
+            (tex_data,
+             tex_name,
+             mtl_name) = mesh.visual.material.to_obj()
+            # add the reference to the MTL file
+            objects.appendleft('mtllib {}'.format(mtl_name))
+            # add the directive to use the exported material
+            export.appendleft('usemtl {}'.format(tex_name))
+        # the format for a single vertex reference of a face
+        face_format = face_formats[tuple(face_type)]
+        # add the exported faces to the export
+        export.append('f ' + util.array_to_string(
+            mesh.faces + 1 + counts['v'],
             col_delim=' ',
-            row_delim='\nvt ',
-            digits=8) + '\n')
-        tex_data, tex_name, mtl_name = mesh.visual.material.to_obj()
-        # add the reference to the MTL file
-        export.appendleft('mtllib {}'.format(mtl_name))
-        # add the directive to use the exported material
-        export.appendleft('usemtl {}'.format(tex_name))
-
-    # the format for a single vertex reference of a face
-    face_format = face_formats[tuple(face_type)]
-    # add the exported faces to the export
-    export.append('f ' + util.array_to_string(
-        mesh.faces + 1,
-        col_delim=' ',
-        row_delim='\nf ',
-        value_format=face_format))
-
-    # add object name if found in metadata
-    if 'name' in mesh.metadata:
-        export.appendleft('o {}'.format(mesh.metadata['name']))
+            row_delim='\nf ',
+            value_format=face_format))
+        # offset our vertex position
+        counts['v'] += len(mesh.vertices)
+
+        # add object name if found in metadata
+        if 'name' in mesh.metadata:
+            export.appendleft(
+                '\no {}'.format(mesh.metadata['name']))
+        # add this object
+        objects.append('\n'.join(export))
 
     # add a created-with header to the top of the file
-    export.appendleft('# https://github.com/mikedh/trimesh')
-
+    objects.appendleft('# https://github.com/mikedh/trimesh')
     # combine elements into a single string
-    export = '\n'.join(export)
-
+    text = '\n'.join(objects)
     # if we exported texture it changes returned values
     if include_texture and tex_data is not None:
-        return export, tex_data
+        return text, tex_data
 
-    return export
+    return text
 
 
 _obj_loaders = {'obj': load_obj}
diff --git a/trimesh/interfaces/generic.py b/trimesh/interfaces/generic.py
index 9ce9d8b8..19d4cd04 100644
--- a/trimesh/interfaces/generic.py
+++ b/trimesh/interfaces/generic.py
@@ -101,7 +101,7 @@ class MeshScript:
 
     def __exit__(self, *args, **kwargs):
         if self.debug:
-            print('MeshScript.debug: not deleting {}'.format(
+            log.info('MeshScript.debug: not deleting {}'.format(
                 self.script_out.name))
             return
         # delete all the temporary files by name
diff --git a/trimesh/path/creation.py b/trimesh/path/creation.py
index 926d25d0..081b9e62 100644
--- a/trimesh/path/creation.py
+++ b/trimesh/path/creation.py
@@ -73,8 +73,8 @@ def circle_pattern(pattern_radius,
 
 def circle(radius=None, center=None, **kwargs):
     """
-    Create a Path2D containing a single or multiple rectangles
-    with the specified bounds.
+    Create a Path2D containing circle with the specified
+    radius.
 
     Parameters
     --------------
@@ -83,8 +83,8 @@ def circle(radius=None, center=None, **kwargs):
 
     Returns
     -------------
-    rect : Path2D
-      Path containing specified rectangles
+    circle : Path2D
+      Path containing specified circle
     """
     from .path import Path2D
 
diff --git a/trimesh/scene/scene.py b/trimesh/scene/scene.py
index 8c43cae1..65b51c5e 100644
--- a/trimesh/scene/scene.py
+++ b/trimesh/scene/scene.py
@@ -377,6 +377,27 @@ class Scene(Geometry):
         centroid = np.mean(self.bounds, axis=0)
         return centroid
 
+    @caching.cache_decorator
+    def area(self):
+        """
+        What is the summed area of every geometry which
+        has area.
+
+        Returns
+        ------------
+        area : float
+          Summed area of every instanced geometry
+        """
+        # get the area of every geometry that has an area property
+        areas = {n: g.area for n, g in self.geometry.items()
+                 if hasattr(g, 'area')}
+        # get the name of every geometry instance in the scene
+        geoms = [self.graph[n][1] for n in
+                 self.graph.nodes_geometry]
+        # sum the area for every instanced geometry
+        area = sum(areas[n] for n in geoms if n in geoms)
+        return area
+
     @caching.cache_decorator
     def triangles(self):
         """
@@ -698,11 +719,16 @@ class Scene(Geometry):
 
     def dump(self, concatenate=False):
         """
-        Append all meshes in scene to a list of meshes.
+        Append all meshes in scene freezing transforms.
+
+        Parameters
+        ------------
+        concatenate : bool
+          If True, concatenate results into single mesh
 
         Returns
         ----------
-        dumped : (n,) list
+        dumped : (n,) Trimesh or Trimesh
           Trimesh objects transformed to their
           location the scene.graph
         """
@@ -713,6 +739,7 @@ class Scene(Geometry):
             current = self.geometry[geometry_name].copy()
             # move the geometry vertices into the requested frame
             current.apply_transform(transform)
+            current.metadata['name'] = node_name
             # save to our list of meshes
             result.append(current)
 
@@ -734,14 +761,20 @@ class Scene(Geometry):
         hull = convex.convex_hull(points)
         return hull
 
-    def export(self, file_obj=None, file_type=None, **kwargs):
+    def export(self,
+               file_obj=None,
+               file_type=None,
+               **kwargs):
         """
         Export a snapshot of the current scene.
 
         Parameters
         ----------
-        file_type: what encoding to use for meshes
-                   ie: dict, dict64, stl
+        file_obj : str, file-like, or None
+          File object to export to
+        file_type : str or None
+          What encoding to use for meshes
+          IE: dict, dict64, stl
 
         Returns
         ----------
@@ -751,11 +784,15 @@ class Scene(Geometry):
 
         # if we weren't passed a file type extract from file_obj
         if file_type is None:
-            file_type = str(file_obj).split('.')[-1]
+            if util.is_string(file_obj):
+                file_type = str(file_obj).split('.')[-1]
+            else:
+                raise ValueError('file_type not specified!')
 
         # always remove whitepace and leading characters
         file_type = file_type.strip().lower().lstrip('.')
 
+        # now handle our different scene export types
         if file_type == 'gltf':
             data = gltf.export_gltf(self, **kwargs)
         elif file_type == 'glb':
@@ -763,11 +800,15 @@ class Scene(Geometry):
         elif file_type == 'dict':
             from ..exchange.export import scene_to_dict
             data = scene_to_dict(self)
+        elif file_type == 'obj':
+            from ..exchange.obj import export_obj
+            data = export_obj(self)
         elif file_type == 'dict64':
             from ..exchange.export import scene_to_dict
             data = scene_to_dict(self, use_base64=True)
         else:
-            raise ValueError('unsupported export format: {}'.format(file_type))
+            raise ValueError(
+                'unsupported export format: {}'.format(file_type))
 
         # now write the data or return bytes of result
         if hasattr(file_obj, 'write'):
@@ -775,8 +816,13 @@ class Scene(Geometry):
             file_obj.write(data)
         elif util.is_string(file_obj):
             # assume strings are file paths
-            file_path = os.path.expanduser(os.path.abspath(file_obj))
-            with open(file_path, 'wb') as f:
+            file_path = os.path.expanduser(
+                os.path.abspath(file_obj))
+            if util.is_string(data):
+                mode = 'w'
+            else:
+                mode = 'wb'
+            with open(file_path, mode) as f:
                 f.write(data)
         else:
             # no writeable file object so return data
diff --git a/trimesh/util.py b/trimesh/util.py
index 480dbb99..7dc4f1a2 100644
--- a/trimesh/util.py
+++ b/trimesh/util.py
@@ -1579,14 +1579,15 @@ def convert_like(item, like):
 
 def bounds_tree(bounds):
     """
-    Given a set of axis aligned bounds create an r-tree for broad-phase
-    collision detection.
+    Given a set of axis aligned bounds create an r-tree for
+    broad-phase collision detection.
 
     Parameters
     ------------
-    bounds : (n, dimension * 2) or (n, 2, dimension) float
-      Non-interleaved bounds, i.e. for a 2D bounds tree:
-        [(minx, miny, maxx, maxy), ...]
+    bounds : (n, 2D) or (n, 2, D) float
+      Non-interleaved bounds where D=dimension
+      E.G a 2D bounds tree:
+      [(minx, miny, maxx, maxy), ...]
 
     Returns
     ---------
@@ -1604,7 +1605,7 @@ def bounds_tree(bounds):
             raise ValueError('bounds not (n, 2, dimension)!')
         # reshape to one-row-per-hyperrectangle
         bounds = bounds.reshape((len(bounds), -1))
-    elif len(bounds.shape) != 2:
+    elif len(bounds.shape) != 2 or bounds.size == 0:
         raise ValueError('Bounds must be (n, dimension * 2)!')
 
     # check to make sure we have correct shape
@@ -1616,9 +1617,11 @@ def bounds_tree(bounds):
     # some versions of rtree screw up indexes on stream loading
     # do a test here so we know if we are free to use stream loading
     # or if we have to do a loop to insert things which is 5x slower
-    rtree_test = rtree.index.Index([(1564, [0, 0, 0, 10, 10, 10], None)],
-                                   properties=rtree.index.Property(dimension=3))
-    rtree_stream_ok = next(rtree_test.intersection([1, 1, 1, 2, 2, 2])) == 1564
+    rtree_test = rtree.index.Index(
+        [(1564, [0, 0, 0, 10, 10, 10], None)],
+        properties=rtree.index.Property(dimension=3))
+    rtree_stream_ok = next(rtree_test.intersection(
+        [1, 1, 1, 2, 2, 2])) == 1564
 
     properties = rtree.index.Property(dimension=dimension)
     if rtree_stream_ok:
diff --git a/trimesh/version.py b/trimesh/version.py
index 742c9618..826cf62c 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.5.25'
+__version__ = '3.6.0'


Test patch:
diff --git a/tests/test_copy.py b/tests/test_copy.py
index 91ec8269..dcc796b6 100644
--- a/tests/test_copy.py
+++ b/tests/test_copy.py
@@ -11,7 +11,7 @@ class CopyTests(g.unittest.TestCase):
 
     def test_copy(self):
         for mesh in g.get_meshes(raise_error=True):
-            if not isinstance(mesh, g.trimesh.Trimesh):
+            if not isinstance(mesh, g.trimesh.Trimesh) or len(mesh.faces) == 0:
                 continue
             start = {mesh.md5(), mesh.crc()}
 
diff --git a/tests/test_except.py b/tests/test_except.py
new file mode 100644
index 00000000..90003b26
--- /dev/null
+++ b/tests/test_except.py
@@ -0,0 +1,32 @@
+try:
+    from . import generic as g
+except BaseException:
+    import generic as g
+
+
+class ExceptionsTest(g.unittest.TestCase):
+
+    def test_module(self):
+        # create an ExceptionModule
+        try:
+            raise ValueError('nah')
+        except BaseException as E:
+            em = g.trimesh.exceptions.ExceptionModule(E)
+
+        # checking isinstance should always return false
+        # and NOT raise the error
+        assert not isinstance(em, dict)
+
+        try:
+            # should re-raise `ValueError('nah')`
+            em.hi()
+            # if we're here raise an error we don't catch
+            raise NameError('should not have worked!!')
+        except ValueError:
+            # should have re-raised ValueError
+            pass
+
+
+if __name__ == '__main__':
+    g.trimesh.util.attach_to_log()
+    g.unittest.main()
diff --git a/tests/test_obj.py b/tests/test_obj.py
index 8b000e70..8fca752c 100644
--- a/tests/test_obj.py
+++ b/tests/test_obj.py
@@ -162,6 +162,17 @@ class OBJTest(g.unittest.TestCase):
         # make sure the faces are the same size
         assert rec.faces.shape == m.faces.shape
 
+    def test_scene(self):
+        s = g.get_mesh('cycloidal.3DXML')
+
+        e = g.trimesh.load(
+            g.io_wrap(s.export(file_type='obj')),
+            file_type='obj',
+            split_object=True,
+            group_materials=False)
+
+        assert g.np.isclose(e.area, s.area, rtol=.01)
+
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_vhacd.py b/tests/test_vhacd.py
index 7276d6aa..5d11b062 100644
--- a/tests/test_vhacd.py
+++ b/tests/test_vhacd.py
@@ -15,11 +15,11 @@ class VHACDTest(g.unittest.TestCase):
             return
 
         g.log.info('testing convex decomposition using vhacd')
-
+        # get a bunny
         mesh = g.get_mesh('bunny.ply')
-
         # run a convex decomposition using vhacd
-        decomposed = mesh.convex_decomposition(maxhulls=10, debug=True)
+        decomposed = mesh.convex_decomposition(
+            maxhulls=10, debug=True)
 
         if len(decomposed) != 10:
             # it should return the correct number of meshes