Repository: mikedh/trimesh
Pull request #1580 (2022-05-02 17:24:21)

Release: Scene Fixes
- release #1564 
- release #1568 
- release #1574
- release #1575  
- release #1571 


Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..a46379e6
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,128 @@
+Contributing To Trimesh
+=======================
+
+Pull requests are appreciated! 
+
+
+## Developer Quick Start
+
+Here's how I set up a new environment and write functions. It's not necessary to do it this way but it does make some things easier! Start with installing some stuff:
+```
+# I create a virtul env I enter every time I open a
+# terminal. There are plenty of alternative
+# options like poetry, pipenv, conda, etc but for simple
+# pip-installing-to-my-home-dir needs this works fine
+python -m venv ~/myenv
+# enter the venv every time you open a new terminal
+echo "source ~/myenv/bin/activate" >> ~/.bashrc
+
+# pip install in our venv
+# no-implicit-concat adds a rule which disallows strings on
+# different lines with no comma being concatenated as this is
+# pretty much always a bug. 
+pip install autopep8 flake8 flake8-no-implicit-concat codespell pyinstrument ipython
+```
+
+I pretty much always start with an interactive terminal (i.e. a "REPL") inside a stub function:
+```
+import numpy as np
+
+def fancy_function(blah):
+    if blah.shape != (3, 3):
+       raise ValueError('this input was goofy!')
+
+    # do some obvious operations and whatnot
+    dots = np.dot(blah, [1,2,3])
+
+    # get a REPL inside my function so I can write each line
+    # with the context of the function, copy paste the lines
+    # in and at the end return the value and remove the embed
+    from IPython import embed
+    embed()
+    
+if __name__ == '__main__':
+    # I like pyinstrument as it's a relatively low-overhead sampling
+    # profiler and has nice looking nested print statements compared
+    # to cProfile or others.
+    import pyinstrument
+    
+    data = np.random.random((3, 3))
+    with pyinstrument.Profiler() as pr:
+        result = fancy_function(data)
+    pr.print()
+```
+
+When you remove the embed and see the profile result you can then tweak the lines that are slow before finishing the function.
+
+Before PR'ing I run some auto-formatting rules which are available in `setup.py`:
+```
+# run autopep8 and yell at you about any flake8 rule violations
+python setup.py --format
+```
+
+
+## General Tips
+
+Python can be fast but only when you use it as little as possible. In general, if you ever have a block which loops through faces and vertices it will be basically unusable with even moderately sized meshes. All operations on face or vertex arrays should be vectorized numpy operations unless absolutely unavoidable. Profiling helps figure out what is slow, but some general advice:
+
+### Helpful
+- Run your test script with `ipython -i newstuff.py` and profile with magic, i.e. `%timeit var.split()`
+- Use `np.fromstring`, `np.frombuffer`
+- Use `str.split` or `np.fromstring`:
+```
+In [6]: %timeit np.array(text.split(), dtype=np.float64)
+1000 loops, best of 3: 209 µs per loop
+
+In [7]: %timeit np.fromstring(text, sep='\n', dtype=np.float64)
+10000 loops, best of 3: 139 µs per loop
+```
+- Use giant format strings rather than looping, appending, or even iterator joining:
+```
+In [14]: array = np.random.random((10000,3))
+
+In [15]: %timeit '\n'.join('{}/{}/{}'.format(*row) for row in array)
+10 loops, best of 3: 60.3 ms per loop
+
+In [16]: %timeit ('{}/{}/{}\n' * len(array))[:-1].format(*array.flatten())
+10 loops, best of 3: 34.3 ms per loop
+```
+- Sometimes you can use sparse matrices to replace a loop and get a huge speedup. [Here's the same algorithm implemented two ways, looping and sparse dot products.](https://github.com/mikedh/trimesh/blob/master/trimesh/geometry.py#L186-L203)
+- In tight loops, `array.sum(axis=1)` often pops up as the slowest thing. This can be replaced with a dot product of ones, which are very optimized can be substantially faster:
+```
+In [1]: import numpy as np
+
+In [2]: a = np.random.random((10000, 3))
+
+In [3]: %timeit a.sum(axis=1)
+10000 loops, best of 3: 157 µs per loop
+
+In [4]: %timeit np.dot(a, [1,1,1])
+10000 loops, best of 3: 25.8 µs per loop
+```
+- If you can use it, `np.concatenate` is usually faster than `np.vstack`, `np.append`, or `np.column_stack`
+```
+In [3]: seq = [np.random.random((int(np.random.random() * 1000), 3)) for i in range(1000)]
+
+In [7]: %timeit np.vstack(seq)
+100 loops, best of 3: 3.48 ms per loop
+
+In [8]: %timeit np.concatenate(seq)
+100 loops, best of 3: 2.33 ms per loop
+```
+- Sometimes `np.bincount` can be used instead of `np.unique` for a substantial speedup:
+```
+In [45]: a = (np.random.random(1000) * 1000).astype(int)
+
+In [46]: set(np.where(np.bincount(a).astype(bool))[0]) == set(np.unique(a))
+Out[46]: True
+
+In [47]: %timeit np.where(np.bincount(a).astype(bool))[0]
+100000 loops, best of 3: 5.81 µs per loop
+
+In [48]: %timeit np.unique(a)
+10000 loops, best of 3: 31.8 µs per loop
+```
+
+### Try To Avoid
+- Looping in general, and *especially* looping on arrays that could have many elements(i.e. vertices and faces). The loop overhead is very high in Python. If necessary to loop you may find that list comprehensions are quite a bit faster (though definitely profile first!) probably for scoping reasons.
+- Boolean operations (i.e. intersection, difference, union) on meshes may seem like the answer, but they are nearly always flaky and slow. The best answer is usually to restructure your problem to use some form of vector checks if possible (i.e. dot products, ray tests, etc). Look at `trimesh.intersections` for an example of a problem that could have used a boolean operation but didn't.
diff --git a/README.md b/README.md
index 4c87ed50..b341223b 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ Trimesh is a pure Python (2.7-3.4+) library for loading and using [triangular me
 
 The API is mostly stable, but this should not be relied on and is not guaranteed: install a specific version if you plan on deploying something using trimesh.
 
-Pull requests are appreciated and responded to promptly! If you'd like to contribute, here is an [up to date list of potential enhancements](https://github.com/mikedh/trimesh/issues/199) although things not on that list are also welcome. Here are some [tips for writing mesh code in Python.](https://github.com/mikedh/trimesh/blob/main/trimesh/exchange/README.md)
+Pull requests are appreciated and responded to promptly! If you'd like to contribute, here is an [up to date list of potential enhancements](https://github.com/mikedh/trimesh/issues/1557) although things not on that list are also welcome. Here are some [tips for writing applications and contributing.](https://github.com/mikedh/trimesh/blob/main/trimesh/CONTRIBUTING.md)
 
 
 ## Basic Installation
@@ -216,7 +216,7 @@ 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](https://hub.docker.com/repository/docker/trimesh/trimesh) with image tags for `latest`, git short hash for the commit in main (i.e. `trimesh/trimesh:0c1298d`), and version (i.e. `trimesh/trimesh:3.5.27`):
+If you want to deploy something in a container that uses trimesh automated `debian:slim-bullseye` based builds with trimesh and most 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 `main` (i.e. `trimesh/trimesh:0c1298d`), and version (i.e. `trimesh/trimesh:3.5.27`):
 
 `docker pull trimesh/trimesh`
 
diff --git a/docs/Makefile b/docs/Makefile
index a12f117a..8fcde202 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -16,7 +16,7 @@ example_names = $(foreach path, $(example_notebooks), $(basename $(notdir $(path
 example_htmls = $(foreach name, $(example_names), $(STATICDIR)/examples/$(name).html)
 example_rsts = $(foreach name, $(example_names), examples.$(name).rst)
 
-html: conf.py index.rst install.rst examples.rst README.rst trimesh.rst $(example_rsts) $(example_htmls) .deps
+html: conf.py index.rst *.md README.rst trimesh.rst $(example_rsts) $(example_htmls) .deps
 	@$(SPHINXBUILD) -M html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
 	touch "$(BUILDDIR)/html/.nojekyll"
 	echo "trimsh.org" > "$(BUILDDIR)/html/CNAME"
@@ -28,21 +28,22 @@ html: conf.py index.rst install.rst examples.rst README.rst trimesh.rst $(exampl
 	$(PIP) install -r requirements.txt
 	$(PIP) freeze > .deps
 
-README.rst: ../README.md .deps
-	pandoc --from=gfm --to=rst --output=README.rst ../README.md
-
 $(STATICDIR)/examples/%.html: ../examples/%.ipynb .deps
 	mkdir -p "$(STATICDIR)/examples"
 	$(NBCONVERT) nbconvert --execute --to html --output $(abspath $@) $<
 
-examples.%.rst: $(STATICDIR)/examples/%.html examples.template
+examples.%.rst: $(STATICDIR)/examples/%.html examples.template 
 	$(PYTHON) -c "open('$@', 'w').write(open('examples.template').read().format(name='$(*F)', url='$<'))"
 
 trimesh.rst: .deps
 	$(SPHINXGEN) -eTf -t "$(TEMPLATESDIR)" -o "$(SOURCEDIR)" ../trimesh
 
+
+README.rst: ../README.md .deps
+	pandoc --from=gfm --to=rst --output=README.rst ../README.md
+
 clean:
-	rm -rvf "$(BUILDDIR)" "$(STATICDIR)/examples" examples.*.rst trimesh*.rst .deps README.rst
+	rm -rvf "$(BUILDDIR)" "$(STATICDIR)/examples" examples.*.rst trimesh*.rst .deps
 
 # # All other Sphinx builders
 #%: conf.py index.rst install.rst examples.rst README.rst trimesh.rst $(example_rsts) $(example_htmls)
diff --git a/docs/_static/custom.css b/docs/_static/custom.css
index 2cb14ee0..82ec91f3 100644
--- a/docs/_static/custom.css
+++ b/docs/_static/custom.css
@@ -1,6 +1,4 @@
 /* override table width restrictions */
-@media screen and (min-width: 767px) {
-
    .wy-table-responsive table td {
       /* !important prevents the common CSS stylesheets from overriding
          this as on RTD they are loaded after this stylesheet */
@@ -10,8 +8,12 @@
    .wy-table-responsive {
       overflow: visible !important;
    }
-}
+
 
 dl.py.property {
     display: unset;
 }
+
+.wy-nav-content {
+    max-width: 70em;
+}
diff --git a/docs/conf.py b/docs/conf.py
index b816c2d3..2b54b723 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -26,21 +26,26 @@ def abspath(rel):
     return os.path.abspath(os.path.join(cwd, rel))
 
 
-extensions = ['sphinx.ext.napoleon']
+extensions = ['sphinx.ext.napoleon',  # numpy-style docstring
+              'myst_parser']         # allows markdown
+myst_all_links_external = True
 
 # Add any paths that contain templates here, relative to this directory.
 templates_path = ['_templates']
 
 # The suffix(es) of source filenames.
-# You can specify multiple suffix as a list of string:
-source_suffix = ['.rst']
+source_suffix = {
+    '.rst': 'restructuredtext',
+    '.txt': 'markdown',
+    '.md': 'markdown',
+}
 
 # The master toctree document.
 master_doc = 'index'
 
 # General information about the project.
 project = 'trimesh'
-copyright = '2020, Michael Dawson-Haggerty'
+copyright = '2022, Michael Dawson-Haggerty'
 author = 'Michael Dawson-Haggerty'
 
 # The version info for the project you're documenting, acts as replacement for
@@ -96,9 +101,7 @@ html_theme_options = {
 html_static_path = ['_static']
 
 # custom css
-html_css_files = [
-    '_static/custom.css',
-]
+html_css_files = ['_static/custom.css']
 
 html_context = {
 
diff --git a/docs/contributing.md b/docs/contributing.md
new file mode 100644
index 00000000..70b70eeb
--- /dev/null
+++ b/docs/contributing.md
@@ -0,0 +1,131 @@
+Contributing To Trimesh
+=======================
+
+Pull requests are always super welcome! Trimesh is a relatively small open source project and really benefits from the bugfixes, features, and other stuff the 100+ contributors have PR'd, so thanks!
+
+
+## Developer Quick Start
+
+Here's how I set up a new environment and write functions. It's not necessary to do it this way but it does make some things easier! If you don't have a "virtual environment" solution there's plenty of ways to do this (poetry, pipenv, conda, etc.) but I just use the `venv` module built into the standard library:
+```
+# create the venv
+python -m venv ~/myenv
+
+# on linux this will use this venv
+# every time you open a new terminal
+echo "source ~/myenv/bin/activate" >> ~/.bashrc
+```
+
+With a virtual environment so pip doesn't dump files everywhere you can install some stuff. The `flake8-no-implicit-concat` adds a rule which disallows strings on different lines with no comma being concatenated as this is pretty much always a bug.
+```
+pip install autopep8 flake8 flake8-no-implicit-concat codespell pyinstrument ipython
+```
+
+I pretty much always start with an interactive terminal (i.e. a "REPL") inside a stub function:
+```
+import numpy as np
+
+def fancy_function(blah):
+    if blah.shape != (3, 3):
+       raise ValueError('this input was goofy!')
+
+    # do some obvious operations and whatnot
+    dots = np.dot(blah, [1,2,3])
+
+    # get a REPL inside my function so I can write each line
+    # with the context of the function, copy paste the lines
+    # in and at the end return the value and remove the embed
+    from IPython import embed
+    embed()
+    
+if __name__ == '__main__':
+    # I like pyinstrument as it's a relatively low-overhead sampling
+    # profiler and has nice looking nested print statements compared
+    # to cProfile or others.
+    import pyinstrument
+    
+    data = np.random.random((3, 3))
+    with pyinstrument.Profiler() as pr:
+        result = fancy_function(data)
+    pr.print()
+```
+
+When you remove the embed and see the profile result you can then tweak the lines that are slow before finishing the function.
+
+### Automatic Formatting
+Before opening a pull request I run some auto-formatting rules which will run autopep8 and yell at you about any flake8 rule violations. There is a convenience script baked into `setup.py` to run all of these which you can run with:
+```
+python setup.py --format
+```
+
+This is equivalent to running `codespell`, `autopep8`, and `flake8` on trimesh, examples, and tests. You can also run it yourself with these options:
+```
+autopep8 --recursive --verbose --in-place --aggressive trimesh
+flake8 trimesh
+```
+
+## General Tips
+
+Python can be fast but only when you use it as little as possible. In general, if you ever have a block which loops through faces and vertices it will be basically unusable with even moderately sized meshes. All operations on face or vertex arrays should be vectorized numpy operations unless absolutely unavoidable. Profiling helps figure out what is slow, but some general advice:
+
+### Helpful
+- Run your test script with `ipython -i newstuff.py` and profile with magic, i.e. `%timeit var.split()`
+- Use `np.fromstring`, `np.frombuffer`
+- Use `str.split` or `np.fromstring`:
+```
+In [6]: %timeit np.array(text.split(), dtype=np.float64)
+1000 loops, best of 3: 209 µs per loop
+
+In [7]: %timeit np.fromstring(text, sep='\n', dtype=np.float64)
+10000 loops, best of 3: 139 µs per loop
+```
+- Use giant format strings rather than looping, appending, or even iterator joining:
+```
+In [14]: array = np.random.random((10000,3))
+
+In [15]: %timeit '\n'.join('{}/{}/{}'.format(*row) for row in array)
+10 loops, best of 3: 60.3 ms per loop
+
+In [16]: %timeit ('{}/{}/{}\n' * len(array))[:-1].format(*array.flatten())
+10 loops, best of 3: 34.3 ms per loop
+```
+- Sometimes you can use sparse matrices to replace a loop and get a huge speedup. [Here's the same algorithm implemented two ways, looping and sparse dot products.](https://github.com/mikedh/trimesh/blob/master/trimesh/geometry.py#L186-L203)
+- In tight loops, `array.sum(axis=1)` often pops up as the slowest thing. This can be replaced with a dot product of ones, which are very optimized can be substantially faster:
+```
+In [1]: import numpy as np
+
+In [2]: a = np.random.random((10000, 3))
+
+In [3]: %timeit a.sum(axis=1)
+10000 loops, best of 3: 157 µs per loop
+
+In [4]: %timeit np.dot(a, [1,1,1])
+10000 loops, best of 3: 25.8 µs per loop
+```
+- If you can use it, `np.concatenate` is usually faster than `np.vstack`, `np.append`, or `np.column_stack`
+```
+In [3]: seq = [np.random.random((int(np.random.random() * 1000), 3)) for i in range(1000)]
+
+In [7]: %timeit np.vstack(seq)
+100 loops, best of 3: 3.48 ms per loop
+
+In [8]: %timeit np.concatenate(seq)
+100 loops, best of 3: 2.33 ms per loop
+```
+- Sometimes `np.bincount` can be used instead of `np.unique` for a substantial speedup:
+```
+In [45]: a = (np.random.random(1000) * 1000).astype(int)
+
+In [46]: set(np.where(np.bincount(a).astype(bool))[0]) == set(np.unique(a))
+Out[46]: True
+
+In [47]: %timeit np.where(np.bincount(a).astype(bool))[0]
+100000 loops, best of 3: 5.81 µs per loop
+
+In [48]: %timeit np.unique(a)
+10000 loops, best of 3: 31.8 µs per loop
+```
+
+### Try To Avoid
+- Looping in general, and *especially* looping on arrays that could have many elements(i.e. vertices and faces). The loop overhead is very high in Python. If necessary to loop you may find that list comprehensions are quite a bit faster (though definitely profile first!) probably for scoping reasons.
+- Boolean operations (i.e. intersection, difference, union) on meshes may seem like the answer, but they are nearly always flaky and slow. The best answer is usually to restructure your problem to use some form of vector checks if possible (i.e. dot products, ray tests, etc). Look at `trimesh.intersections` for an example of a problem that could have used a boolean operation but didn't.
diff --git a/docs/examples.md b/docs/examples.md
new file mode 100644
index 00000000..9174f13a
--- /dev/null
+++ b/docs/examples.md
@@ -0,0 +1,27 @@
+Examples
+========
+
+A few examples are available as rendered IPython notebooks.
+
+
+### Quick Tour
+
+A tour of some basic features, like inertia, viewing in notebooks, etc. [Quick start](./examples.quick_start.html)
+
+### Cross Sections
+
+How to take planar slices of a mesh, like you would for 3D printing using trimesh. [Cross sections](./examples.section.html)
+
+
+### Proximity Queries
+
+How do do signed distance and proximity queries on meshes. [Nearest point queries](examples.nearest.html)
+
+### Path Finding
+
+Find a path from one mesh vertex to another, travelling along edges of the mesh. [Edge graph traversal](examples.shortest.html)
+
+
+### Ray Tests
+
+Do simple ray-mesh queries, including finding the indexes of triangles hit by rays, the locations of points hit on the mesh surface, etc. Ray queries have the same API with two available backends, one implemented with just Numpy calls, and one that requires pyembree but is 50x faster (a team at Intel wrote it). [Ray-mesh queries](examples.ray.html)
diff --git a/docs/examples.rst b/docs/examples.rst
deleted file mode 100644
index 63e86c64..00000000
--- a/docs/examples.rst
+++ /dev/null
@@ -1,37 +0,0 @@
-Examples
-=====================
-
-A few examples are available as rendered IPython notebooks.
-
-
-	  
-Quick Tour
-'''''''''''''
-
-A tour of some basic features, like inertia, viewing in notebooks, etc. 
-`Quick start <examples/quick_start.html>`_
-
-
-Cross Sections
-'''''''''''''
-
-How to take planar slices of a mesh, like you would for 3D printing, using trimesh.
-`Cross sections <examples/section.html>`_
-
-Proximity Queries
-'''''''''''''''''
-
-How do do signed distance and proximity queries on meshes.
-`Nearest point queries <examples/nearest.html>`_
-
-Path Finding
-'''''''''''''
-
-Find a path from one mesh vertex to another, travelling along edges of the mesh.
-`Edge graph traversal <examples/shortest.html>`_
-
-Ray Tests
-'''''''''''''
-
-Do simple ray- mesh queries, including finding the indexes of triangles hit by rays, the locations of points hit on the mesh surface, etc. Ray queries have the same API with two available backends, one implemented with just Numpy calls, and one that requires pyembree but is 50x faster (a team at Intel wrote it).
-`Ray-mesh queries <examples/ray.html>`_
diff --git a/docs/index.rst b/docs/index.rst
index 1509bdae..c41b4fa6 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -6,21 +6,28 @@
 Links
 ==========
 .. toctree::
-   Trimesh On Github <http://github.com/mikedh/trimesh>
+   Github <http://github.com/mikedh/trimesh>
 
 Install
 ==========
 .. toctree::
    :maxdepth: 2
 	      
-   install
+   install.md
 
 Examples
 ==========
 .. toctree::
    :maxdepth: 2
 	      
-   examples
+   examples.md
+
+Contributing
+==========  
+.. toctree::
+   :maxdepth: 1
+
+   Contributing <contributing.md>
 
 API Reference
 =============
@@ -28,3 +35,4 @@ API Reference
    :maxdepth: 1
    
    API Reference <trimesh>
+
diff --git a/docs/install.md b/docs/install.md
new file mode 100644
index 00000000..20972aa5
--- /dev/null
+++ b/docs/install.md
@@ -0,0 +1,80 @@
+Installation
+=====================
+
+The only thing required to install `trimesh` is
+[numpy](http://www.numpy.org/).
+
+All other dependencies are \'soft,\' or trimesh will raise the exceptions (usually but not always an `ImportError`) at runtime if a function is called that requires a package that isn\'t installed. If you do the most basic install of `trimesh` it will only install `numpy`:
+
+```
+pip install trimesh
+```
+
+This will enable you to load most formats into numpy arrays: STL, PLY, OBJ, GLB, GLTF.
+
+If you\'d like most soft dependencies which should install cleanly on Mac, Windows, and Linux, you can use the `easy` pip extra:
+
+```
+pip install trimesh[easy]
+```
+
+Or if you want the full experience, you can try the `all` extra, where packages may only be available for Linux:
+```
+pip install trimesh[all]
+```
+
+
+Conda Packages
+--------------
+
+If you prefer a `conda` environment, `trimesh` is available on `conda-forge` ([trimesh-feedstock repo](https://github.com/conda-forge/trimesh-feedstock))
+
+
+If you install [Miniconda](https://conda.io/docs/install/quick.html) you can then run:
+
+```
+conda install -c conda-forge trimesh
+```
+      
+Ubuntu-Debian Notes
+-------------------
+
+Blender and openSCAD are soft dependencies used for boolean operations with subprocess, you can get them with `apt`:
+
+```
+sudo apt-get install openscad blender
+```
+
+Dependency Overview
+--------------------
+
+Trimesh has a lot of soft-required upstream packages. We try to make sure they're active and big-ish. Here's a quick summary of what they're used for.
+
+ 
+| Package | Description | Alternatives | Level |
+| ------  | ---------   | ----------   | ----- |
+| `numpy` | The base container for fast array types. | None. | `required` | 
+| `scipy` | Provides convex hulls (`scipy.spatial.ConvexHull`), fast graph operations (`scipy.sparse.csgraph`), fast nearest-point queries (`scipy.spatial.cKDTree`), b-spline evaluation (`scipy.interpolate`). | Nothing comes to mind, it does a whole heck of a lot. | `easy` |  
+| `lxml` | Parse XML documents. We use this over the built-in ones as it was slightly faster, and there was a format implemented which was extremely annoying to handle without the ability to get parent nodes (which `lxml` has but built-in XML doesn't). | Standard library's XML | `easy` |
+| `networkx` | A nice-to-use pure Python graph library that's faster than you'd think. It implements DFS, BFS, and the usual FAANG-interview-question algorithms. A lot of the commonly run stuff in trimesh has been re-written to use `scipy.sparse.csgraph` as it's also an easy install and is way faster in most cases. But if you have a small-ish graph the API for `networkx` is way easier to "grok". | `graph-tool`, `scipy.sparse.csgraph` | `easy` |
+| `shapely` | Bindings to `GEOS` for 2D spatial stuff: "set-theoretic analysis and manipulation of planar features." It lets you offset, union, and query polygons nicely. | `clipper` maybe? | `easy` | 
+| `rtree` | Query ND rectangles with a spatial tree for a "broad phase" intersection. Used in polygon generation ("given N closed curves which curve contains the other curve?") and as the broad-phase for the built-in-numpy slow ray query engine. | `fcl` maybe? | `easy` |
+|`requests`| Do network queries in `trimesh.exchange.load_remote`, will *only* make network requests when asked | | `easy`|
+|`sympy`| Evaluate symbolic algebra | | `easy`|
+|`xxhash`| Quickly hash arrays, used for our cache checking | | `easy`|
+|`msgpack`| A serialization method that supports bytes-blobs. | `protobuf` | `easy`|
+|`chardet`| When we fail to decode text as UTF-8 we then check with chardet which guesses an encoding. This lets us load files even with weird encodings. | | `easy`|
+|`colorlog`| Printing logs with colors. | | `easy`|
+|`pillow`| Reading raster images for textures, and rendering polygons into raster images. | | `easy`|
+|`svg.path`| Parsing SVG path strings. | | `easy`|
+|`jsonschema`| Validating our exports for formats like GLTF. | | `easy`|
+|`pycollada`| Parse `dae` files. | | `easy`|
+|`pyglet`| OpenGL bindings for our simple debug viewer. | | `easy`|
+|`xatlas`| Unwrap meshes to generate UV coordinates quickly and well. | | `all`|
+|`python-fcl`| Do collision queries between meshes | | `all`|
+|`glooey`| Provide a viewer with widgets. | | `all`|
+|`meshio`| Load additional mesh formats. | | `all`|
+|`scikit-image`| Used in voxel ops | | `all`|
+|`triangle`| Triangulate 2D polygons, with somewhat unclear licensing. | `mapbox-earcut` | `all`|
+|`mapbox-earcut`| Triangulate 2D polygons, with a permissive license but mostly abandoned | `triangle` | `all`|
+|`psutil`| Get current memory usage, useful for checking to see if we're going to run out of memory instantiating a giant array | | `all`|
\ No newline at end of file
diff --git a/docs/install.rst b/docs/install.rst
deleted file mode 100644
index 28081a8b..00000000
--- a/docs/install.rst
+++ /dev/null
@@ -1,48 +0,0 @@
-Advanced Installation
-=====================
-
-The only thing required to install ``trimesh`` is `numpy <http://www.numpy.org/>`__.
-
-All other dependencies are 'soft,' or trimesh will raise the ``ImportError`` at runtime if a function is called that requires a package that isn't installed. If you do the most basic install of ``trimesh`` it will only install ``numpy``:
-
-.. code:: bash
-
-   pip install trimesh
-
-If you'd like most soft dependencies which should install cleanly, you can use the ``easy`` pip extra:
-
-.. code:: bash
-
-   pip install trimesh[easy]
-
-
-	  
-Conda Install
-'''''''''''''
-
-The easiest way to get going on the most platforms is through a Python provided by conda. You can install `Miniconda <https://conda.io/docs/install/quick.html>`__ easily on all major platforms. Then, to install ``trimesh``:
-
-.. code:: bash
-
-   conda install -c conda-forge scikit-image shapely rtree pyembree
-
-   # install trimesh and all possible dependencies
-   # if this fails try: pip install trimesh[easy]
-   pip install trimesh[all]
-
-
-Ubuntu Notes
-''''''''''''''''''
-
-Blender and openSCAD are soft dependencies used for boolean operations with subprocess, you can get them with apt:
-
-.. code:: bash
-
-   sudo apt-get install openscad blender
-
-Windows Notes
-''''''''''''''''''''
-
-The easiest way to get going on Windows is to install the `Anaconda
-Python distribution <https://www.continuum.io/downloads>`__.
-
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 7dc45e10..7ba70e25 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,12 +1,13 @@
-pypandoc==1.7.4
+pypandoc==1.7.5
 numpy==1.22.3
 scipy==1.8.0
-networkx==2.7.1
+networkx==2.8
 recommonmark==0.7.1
-sphinx==4.4.0
+sphinx==4.5.0
 jupyter==1.0.0
 sphinx_rtd_theme==1.0.0
+myst-parser==0.17.2
 pyopenssl==22.0.0
-autodocsumm==0.2.7
-jinja2==3.0.3
-matplotlib==3.5.1
+autodocsumm==0.2.8
+jinja2==3.1.2
+matplotlib==3.5.2
diff --git a/setup.py b/setup.py
index 4581d23c..e85da348 100644
--- a/setup.py
+++ b/setup.py
@@ -83,6 +83,41 @@ for max_python, name, version in lock:
             # add working version locked requirements
             requirements_easy.add('{}=={}'.format(name, version))
 
+
+def format_all():
+    """
+    A shortcut to run automatic formatting and complaining
+    on all of the trimesh subdirectories.
+    """
+    import subprocess
+
+    def run_on(target):
+        # words that codespell hates
+        # note that it always checks against the lower case
+        word_skip = "datas,coo,nd,files',filetests,ba,childs,whats"
+        # files to skip spelling on
+        file_skip = "*.pyc,*.zip,.DS_Store,*.js,./trimesh/resources"
+        spell = ['codespell', '-i', '3',
+                 '--skip=' + file_skip,
+                 '-L', word_skip, '-w', target]
+        print("Running: \n {} \n\n\n".format(' '.join(spell)))
+        subprocess.check_call(spell)
+
+        formatter = ["autopep8", "--recursive", "--verbose",
+                     "--in-place", "--aggressive", target]
+        print("Running: \n {} \n\n\n".format(
+            ' '.join(formatter)))
+        subprocess.check_call(formatter)
+
+        flake = ['flake8', target]
+        print("Running: \n {} \n\n\n".format(' '.join(flake)))
+        subprocess.check_call(flake)
+
+    # run on our target locations
+    for t in ['trimesh', 'tests', 'examples']:
+        run_on(t)
+
+
 # if someone wants to output a requirements file
 # `python setup.py --list-all > requirements.txt`
 if '--list-all' in sys.argv:
@@ -93,6 +128,10 @@ elif '--list-easy' in sys.argv:
     # again will not include numpy+setuptools
     print('\n'.join(requirements_easy))
     exit()
+elif '--format' in sys.argv:
+    format_all()
+    exit()
+
 
 # call the magical setuptools setup
 setup(name='trimesh',
diff --git a/trimesh/base.py b/trimesh/base.py
index bcea26db..88cb1706 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -762,7 +762,7 @@ class Trimesh(Geometry3D):
 
         Returns
         -----------
-        symmetry: None, 'radial', 'spherical'
+        symmetry : None, 'radial', 'spherical'
           What kind of symmetry does the mesh have.
         """
         symmetry, axis, section = inertia.radial_symmetry(self)
diff --git a/trimesh/exchange/README.md b/trimesh/exchange/README.md
deleted file mode 100644
index ca5e1212..00000000
--- a/trimesh/exchange/README.md
+++ /dev/null
@@ -1,71 +0,0 @@
-trimesh.exchange
-============
-
-Mesh importers and exporters.
-
-
-## Tips for New Importers/Exporters/Code
-
-Python can be fast, but only when you use it as little as possible. In general, if you ever have a block which loops through faces and vertices it will be basically unusable with even moderately sized meshes. All operations on face or vertex arrays should be vectorized numpy operations unless absolutely unavoidable. Profiling helps figure out what is slow, but some general advice:
-
-### Do
-- Run your test script with `ipython -i newstuff.py` and profile with magic, i.e. `%timeit var.split()`
-- Use `np.fromstring`, `np.frombuffer`
-- Use `str.split` or `np.fromstring`:
-```
-In [6]: %timeit np.array(text.split(), dtype=np.float64)
-1000 loops, best of 3: 209 µs per loop
-
-In [7]: %timeit np.fromstring(text, sep='\n', dtype=np.float64)
-10000 loops, best of 3: 139 µs per loop
-```
-- Use giant format strings rather than looping, appending, or even iterator joining:
-```
-In [14]: array = np.random.random((10000,3))
-
-In [15]: %timeit '\n'.join('{}/{}/{}'.format(*row) for row in array)
-10 loops, best of 3: 60.3 ms per loop
-
-In [16]: %timeit ('{}/{}/{}\n' * len(array))[:-1].format(*array.flatten())
-10 loops, best of 3: 34.3 ms per loop
-```
-- Sometimes you can use sparse matrices to replace a loop and get a huge speedup. [Here's the same algorithm implemented two ways, looping and sparse dot products.](https://github.com/mikedh/trimesh/blob/master/trimesh/geometry.py#L186-L203)
-- In tight loops, `array.sum(axis=1)` often pops up as the slowest thing. This can be replaced with a dot product of ones, which are very optimized can be substantially faster:
-```
-In [1]: import numpy as np
-
-In [2]: a = np.random.random((10000, 3))
-
-In [3]: %timeit a.sum(axis=1)
-10000 loops, best of 3: 157 µs per loop
-
-In [4]: %timeit np.dot(a, [1,1,1])
-10000 loops, best of 3: 25.8 µs per loop
-```
-- If you can use it, `np.concatenate` is usually faster than `np.vstack`, `np.append`, or `np.column_stack`
-```
-In [3]: seq = [np.random.random((int(np.random.random() * 1000), 3)) for i in range(1000)]
-
-In [7]: %timeit np.vstack(seq)
-100 loops, best of 3: 3.48 ms per loop
-
-In [8]: %timeit np.concatenate(seq)
-100 loops, best of 3: 2.33 ms per loop
-```
-- Sometimes `np.bincount` can be used instead of `np.unique` for a substantial speedup:
-```
-In [45]: a = (np.random.random(1000) * 1000).astype(int)
-
-In [46]: set(np.where(np.bincount(a).astype(bool))[0]) == set(np.unique(a))
-Out[46]: True
-
-In [47]: %timeit np.where(np.bincount(a).astype(bool))[0]
-100000 loops, best of 3: 5.81 µs per loop
-
-In [48]: %timeit np.unique(a)
-10000 loops, best of 3: 31.8 µs per loop
-```
-
-# Try To Avoid
-- Looping in general, and *especially* looping on arrays that could have many elements(i.e. vertices and faces). The loop overhead is very high in Python, and if necessary list comprehensions are quite a bit faster probably for scoping reasons.
-- Boolean operations on meshes: they may seem like the answer, but they are nearly always flaky, slow, and unreliable. The best answer is usually to restructure your problem to use some form of vector checks if possible(i.e. dot products, ray tests, etc). Look at `trimesh.intersections` for examples of problems that could have used a boolean but didn't.
diff --git a/trimesh/grouping.py b/trimesh/grouping.py
index a94e7ee1..8589188a 100644
--- a/trimesh/grouping.py
+++ b/trimesh/grouping.py
@@ -458,7 +458,7 @@ def unique_rows(data, digits=None, keep_order=False):
     # we are throwing away the first value which is the
     # garbage row-hash and only returning index and inverse
     if keep_order:
-        # keeps order of original occurance
+        # keeps order of original occurrence
         return unique_ordered(
             rows, return_index=True, return_inverse=True)[1:]
     # returns values sorted by row-hash but since our row-hash
diff --git a/trimesh/path/segments.py b/trimesh/path/segments.py
index 01ddbf67..5475aa05 100644
--- a/trimesh/path/segments.py
+++ b/trimesh/path/segments.py
@@ -55,7 +55,7 @@ def segments_to_parameters(segments):
 
     # parametric start and end of line segment
     parameters = np.column_stack((offset, offset + vectors_norm))
-    # make sure signs are consistant
+    # make sure signs are consistent
     vectors, signs = util.vector_hemisphere(
         vectors, return_sign=True)
     parameters *= signs.reshape((-1, 1))
diff --git a/trimesh/path/simplify.py b/trimesh/path/simplify.py
index 329be371..a6a58567 100644
--- a/trimesh/path/simplify.py
+++ b/trimesh/path/simplify.py
@@ -202,6 +202,7 @@ def merge_colinear(points, scale):
     # and direction vectors A-B, B-C, etc
     # these will be perpendicular to the vectors A-C, B-D, etc
     perp = (points[2:] - points[:-2]).T[::-1].T
+    perp[:, 0] *= -1
     perp_norm = util.row_norm(perp)
     perp_nonzero = perp_norm > tol.merge
     perp[perp_nonzero] /= perp_norm[perp_nonzero].reshape((-1, 1))
diff --git a/trimesh/points.py b/trimesh/points.py
index 3df29840..81d2b321 100644
--- a/trimesh/points.py
+++ b/trimesh/points.py
@@ -97,10 +97,11 @@ def plane_fit(points):
 
 def radial_sort(points,
                 origin,
-                normal):
+                normal,
+                start=None):
     """
     Sorts a set of points radially (by angle) around an
-    an axis specified by origin and normal vector.
+    axis specified by origin and normal vector.
 
     Parameters
     --------------
@@ -110,6 +111,10 @@ def radial_sort(points,
       Origin to sort around
     normal : (3,)  float
       Vector to sort around
+    start : (3,) float
+      Vector to specify start position in counter-clockwise
+      order viewing in direction of normal, MUST not be
+      parallel with normal
 
     Returns
     --------------
@@ -117,19 +122,23 @@ def radial_sort(points,
       Same as input points but reordered
     """
 
-    # create two axis perpendicular to each other and the normal,
-    # and project the points onto them
-    axis0 = [normal[0], normal[2], -normal[1]]
-    axis1 = np.cross(normal, axis0)
-    ptVec = points - origin
-    pr0 = np.dot(ptVec, axis0)
-    pr1 = np.dot(ptVec, axis1)
-
+    # create two axis perpendicular to each other and
+    # the normal and project the points onto them
+    if start is None:
+        axis0 = [normal[0], normal[2], -normal[1]]
+        axis1 = np.cross(normal, axis0)
+    else:
+        normal, start = util.unitize([normal, start])
+        if np.abs(1 - np.abs(np.dot(normal, start))) < tol.zero:
+            raise ValueError('start must not parallel with normal')
+        axis0 = np.cross(start, normal)
+        axis1 = np.cross(axis0, normal)
+    vectors = points - origin
     # calculate the angles of the points on the axis
-    angles = np.arctan2(pr0, pr1)
-
+    angles = np.arctan2(np.dot(vectors, axis0),
+                        np.dot(vectors, axis1))
     # return the points sorted by angle
-    return points[[np.argsort(angles)]]
+    return points[angles.argsort()[::-1]]
 
 
 def project_to_plane(points,
@@ -150,7 +159,7 @@ def project_to_plane(points,
     plane_origin : (3,)
       Origin point of plane
     transform : None or (4, 4) float
-       Homogeneous transform, if specified, normal+origin are overridden
+      Homogeneous transform, if specified, normal+origin are overridden
     return_transform : bool
       Returns the (4, 4) matrix used or not
     return_planar : bool
@@ -646,4 +655,15 @@ class PointCloud(Geometry3D):
                            **kwargs)
 
     def __add__(self, other):
-        return PointCloud(vertices=np.vstack((self.vertices, other.vertices)))
+        if len(other.colors) == len(self.colors) == 0:
+            colors = None
+        else:
+            # preserve colors
+            # if one point cloud has no color property use black
+            other_colors = [[0, 0, 0, 255]] * \
+                len(other.vertices) if len(other.colors) == 0 else other.colors
+            self_colors = [[0, 0, 0, 255]] * \
+                len(self.vertices) if len(self.colors) == 0 else self.colors
+            colors = np.vstack((self_colors, other_colors))
+        return PointCloud(vertices=np.vstack(
+            (self.vertices, other.vertices)), colors=colors)
diff --git a/trimesh/primitives.py b/trimesh/primitives.py
index be031af5..6bf6a58a 100644
--- a/trimesh/primitives.py
+++ b/trimesh/primitives.py
@@ -4,8 +4,8 @@ primitives.py
 
 Subclasses of Trimesh objects that are parameterized as primitives.
 
-Useful because you can move boxes and spheres around, and then use
-trimesh operations on them at any point.
+Useful because you can move boxes and spheres around
+and then use trimesh operations on them at any point.
 """
 import numpy as np
 import pprint
@@ -159,7 +159,6 @@ class _PrimitiveAttributes(object):
         # this is generated dynamically as the format operation can be surprisingly
         # slow and if generated in __init__ it is called a lot of times
         # when we didn't really need to generate it
-
         doc = (
             'Store the attributes of a {name} object.\n\n' +
             'When these values are changed, the mesh geometry will \n' +
@@ -418,7 +417,8 @@ class Sphere(_Primitive):
 
         Parameters
         ------------
-        matrix: (4,4) float, homogeneous transformation
+        matrix : (4, 4) float
+          Homogeneous transformation
         """
         matrix = np.asanyarray(matrix, dtype=np.float64)
         if matrix.shape != (4, 4):
@@ -499,20 +499,20 @@ class Box(_Primitive):
 
     def __init__(self, *args, **kwargs):
         """
-        Create a Box Primitive, a subclass of Trimesh
+        Create a Box Primitive as a subclass of Trimesh
 
         Parameters
         ----------
-        extents:   (3,)  float, size of box
-        transform: (4,4) float, transformation matrix for box center
+        extents : (3,) float
+          Length of each side of the 3D box
+        transform : (4, 4) float
+          Homogeneous transformation matrix for box center
         """
         super(Box, self).__init__(*args, **kwargs)
-
         defaults = {'transform': np.eye(4),
                     'extents': np.ones(3)}
-        self.primitive = _PrimitiveAttributes(self,
-                                              defaults,
-                                              kwargs)
+        self.primitive = _PrimitiveAttributes(
+            self, defaults, kwargs)
 
     def sample_volume(self, count):
         """
@@ -590,7 +590,8 @@ class Box(_Primitive):
 
         Returns
         --------
-        volume: float, volume of box
+        volume : float
+          Volume of box.
         """
         volume = float(np.product(self.primitive.extents))
         return volume
diff --git a/trimesh/ray/ray_pyembree.py b/trimesh/ray/ray_pyembree.py
index 470cc337..5b9603c5 100644
--- a/trimesh/ray/ray_pyembree.py
+++ b/trimesh/ray/ray_pyembree.py
@@ -144,11 +144,13 @@ class RayMeshIntersector(object):
           Intersection points, only returned if return_locations
         """
         # make sure input is _dtype for embree
-        ray_origins = np.asanyarray(
+        ray_origins = np.array(
             deepcopy(ray_origins),
             dtype=np.float64)
         ray_directions = np.asanyarray(ray_directions,
                                        dtype=np.float64)
+        if ray_origins.shape != ray_directions.shape:
+            raise ValueError('Ray origin and direction don\'t match!')
         ray_directions = util.unitize(ray_directions)
 
         # since we are constructing all hits, save them to a deque then
@@ -178,10 +180,10 @@ class RayMeshIntersector(object):
             # run the pyembree query
             # if you set output=1 it will calculate distance along
             # ray, which is bizzarely slower than our calculation
+
             query = self._scene.run(
                 ray_origins[current],
                 ray_directions[current])
-
             # basically we need to reduce the rays to the ones that hit
             # something
             hit = query != -1
diff --git a/trimesh/scene/scene.py b/trimesh/scene/scene.py
index 071e6971..efc51a64 100644
--- a/trimesh/scene/scene.py
+++ b/trimesh/scene/scene.py
@@ -799,7 +799,7 @@ class Scene(Geometry3D):
         edges = [e for e in graph.to_edgelist()
                  if e[0] in nodes]
 
-        # create a scene graph whet
+        # create a scene graph when
         graph = SceneGraph(base_frame=node)
         graph.from_edgelist(edges)
 
@@ -1174,7 +1174,7 @@ def split_scene(geometry, **kwargs):
     return scene
 
 
-def append_scenes(iterable, common=['world']):
+def append_scenes(iterable, common=['world'], base_frame='world'):
     """
     Concatenate multiple scene objects into one scene.
 
@@ -1184,6 +1184,8 @@ def append_scenes(iterable, common=['world']):
        Geometries that should be appended
     common : (n,) str
        Nodes that shouldn't be remapped
+    base_frame : str
+       Base frame of the resulting scene
 
     Returns
     ------------
@@ -1278,7 +1280,7 @@ def append_scenes(iterable, common=['world']):
         consumed.update(current)
 
     # add all data to a new scene
-    result = Scene()
+    result = Scene(base_frame=base_frame)
     result.graph.from_edgelist(edges)
     result.geometry.update(geometry)
 
diff --git a/trimesh/scene/transforms.py b/trimesh/scene/transforms.py
index a94c7b0f..ac0141ec 100644
--- a/trimesh/scene/transforms.py
+++ b/trimesh/scene/transforms.py
@@ -168,7 +168,7 @@ class SceneGraph(object):
         copied : TransformForest
           Copy of current object.
         """
-        # create a copy without transfering cache
+        # create a copy without transferring cache
         copied = SceneGraph()
         copied.base_frame = deepcopy(self.base_frame)
         copied.transforms = deepcopy(self.transforms)
@@ -642,7 +642,7 @@ class EnforcedForest(object):
                 self._cache[(u, v)] = backward
                 return backward
             elif forward[-1] is None and backward[-1] is None:
-                raise ValueError('No path between nodes!')
+                raise ValueError('No path between nodes {} and {}!'.format(u, v))
         raise ValueError('Iteration limit exceeded!')
 
     @property
diff --git a/trimesh/version.py b/trimesh/version.py
index 669870b1..447562b9 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.11.2'
+__version__ = '3.12.0'
diff --git a/trimesh/viewer/notebook.py b/trimesh/viewer/notebook.py
index 2e3adb51..bcb49915 100644
--- a/trimesh/viewer/notebook.py
+++ b/trimesh/viewer/notebook.py
@@ -71,11 +71,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
+    # div is to avoid IPython's pointless hardcoded warning
     embedded = display.HTML(' '.join([
-        '<iframe srcdoc="{srcdoc}"',
+        '<div><iframe srcdoc="{srcdoc}"',
         'width="100%" height="{height}px"',
-        'style="border:none;"></iframe>']).format(
+        'style="border:none;"></iframe></div>']).format(
             srcdoc=srcdoc,
             height=height))
     return embedded


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 3c3dcf46..e9c0a5a1 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -31,7 +31,7 @@ jobs:
     runs-on: ${{ matrix.os }}
     strategy:
       matrix:
-        python-version: [3.5, 3.8]
+        python-version: [3.8]
         os: [ubuntu-latest, windows-latest, macos-latest]
         include:
           - os: ubuntu-latest
diff --git a/tests/test_points.py b/tests/test_points.py
index 5009c9d5..c382a18f 100644
--- a/tests/test_points.py
+++ b/tests/test_points.py
@@ -225,6 +225,56 @@ class PointsTest(g.unittest.TestCase):
         assert culled.shape == (100, 3)
         assert mask.shape == (200,)
 
+    def test_add_operator(self):
+        points_1 = g.np.random.random((10, 3))
+        points_2 = g.np.random.random((20, 3))
+        colors_1 = [[123, 123, 123, 255]] * len(points_1)
+        colors_2 = [[255, 0, 123, 255]] * len(points_2)
+
+        # Test: Both cloud's colors are preserved
+        cloud_1 = g.trimesh.points.PointCloud(points_1, colors=colors_1)
+        cloud_2 = g.trimesh.points.PointCloud(points_2, colors=colors_2)
+
+        cloud_sum = cloud_1 + cloud_2
+        assert g.np.allclose(
+            cloud_sum.colors, g.np.vstack(
+                (cloud_1.colors, cloud_2.colors)))
+
+        # Next test: Only second cloud has colors
+        cloud_1 = g.trimesh.points.PointCloud(points_1)
+        cloud_2 = g.trimesh.points.PointCloud(points_2, colors=colors_2)
+
+        cloud_sum = cloud_1 + cloud_2
+        assert g.np.allclose(cloud_sum.colors[len(cloud_1.vertices):], cloud_2.colors)
+
+        # Next test: Only first cloud has colors
+        cloud_1 = g.trimesh.points.PointCloud(points_1, colors=colors_1)
+        cloud_2 = g.trimesh.points.PointCloud(points_2)
+
+        cloud_sum = cloud_1 + cloud_2
+        assert g.np.allclose(cloud_sum.colors[:len(cloud_1.vertices)], cloud_1.colors)
+
+    def test_radial_sort(self):
+        theta = g.np.linspace(0.0, g.np.pi * 2.0, 1000)
+        points = g.np.column_stack((
+            g.np.cos(theta),
+            g.np.sin(theta),
+            g.np.zeros(len(theta))))
+        points *= g.random(len(theta)).reshape((-1, 1))
+
+        # apply a random order to the points
+        order = g.np.random.permutation(
+            g.np.arange(len(points)))
+
+        # get the sorted version of these points
+        # when we pass them the randomly ordered points
+        sort = g.trimesh.points.radial_sort(
+            points[order],
+            origin=[0, 0, 0],
+            normal=[0, 0, 1])
+        # should have re-established original order
+        assert g.np.allclose(points, sort)
+
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_scene.py b/tests/test_scene.py
index 01fabd3b..72d8d0d2 100644
--- a/tests/test_scene.py
+++ b/tests/test_scene.py
@@ -390,6 +390,15 @@ class SceneTests(g.unittest.TestCase):
         # nothing should have changed
         assert original.identifier_md5 == original_hash
 
+    def test_append_scenes(self):
+        scene_0 = g.trimesh.Scene(base_frame='not_world')
+        scene_1 = g.trimesh.Scene(base_frame='not_world')
+
+        scene_sum = g.trimesh.scene.scene.append_scenes(
+            (scene_0, scene_1), common=['not_world'], base_frame='not_world')
+
+        assert scene_sum.graph.base_frame == 'not_world'
+
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_simplify.py b/tests/test_simplify.py
index 01eed496..8880b904 100644
--- a/tests/test_simplify.py
+++ b/tests/test_simplify.py
@@ -24,7 +24,7 @@ class SimplifyTest(g.unittest.TestCase):
             # the simplified version shouldn't have lost area
             assert g.np.allclose(path.area,
                                  simplified.area,
-                                 rtol=1e-3)
+                                 rtol=1e-2)
 
             # see if we fit as many arcs as existed in the original drawing
             new_count = sum(int(type(i).__name__ == 'Arc')
@@ -87,6 +87,17 @@ class SimplifyTest(g.unittest.TestCase):
         simple = path_2D.simplify_spline(smooth=0.01)
         assert g.np.isclose(path_2D.area, simple.area, rtol=.01)
 
+    def test_merge_colinear(self):
+        num = 100
+        dists = g.np.linspace(0, 1000, num=num)
+        direction = g.trimesh.unitize([1, g.np.random.rand()])
+        points = direction * dists.reshape((-1, 1))
+        merged = g.trimesh.path.simplify.merge_colinear(points, scale=1000)
+        print('direction:', direction)
+        print('points:', points.shape)
+        print('merged:', merged.shape)
+        assert merged.shape[0] == 2
+
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()