Repository: mikedh/trimesh
Pull request #2278 (2024-09-01 02:41:46)

Release: Scene Transform
- fix that `scene.scaled` returned an invalid camera if it had been defined.
- add `trimesh.scene.reconstruct_instances` which uses `trimesh.registration.procrustes` and is actually very fast (10x faster on some simple data than `scene.duplicate_nodes`) and gives great results on baked scenes. 
  - add a bunch of type hints to `trimesh.registration` 
- add a `Scene.simplify_quadric_decimation` which runs `mesh.simplify_quadric_decimation` for every mesh geometry in the scene. 
- release #2281 
- release #2279
  - Also add a `trimesh.convex.QhullOptions` dataclass with a blurb for every boolean flag option in https://github.com/mikedh/trimesh/pull/2278/commits/8b2d46e7cb5a17233075a2c32ec7f617b2efeb6e, so you can construct this string with something like `QhullOptions(QbB=True)` and read the docstrings as you're doing it.
- release #2280 
- set the `VIRTUAL_ENV` variable in the Dockerfile which lets upstream potentially use `uv`.
- Attempt to add a `body` to the Github releases in CI. 
- Deprecations for April 2025
    -  `Scene.dump(concatenate=True)` which should be replaced with `Scene.to_geometry()` or `Scene.to_mesh()`, as otherwise the return types are inconsistent. This also lays some groundwork for #2241 as `trimesh.load_mesh` will be a thin wrapper around `trimesh.load_scene(...).to_mesh()`
    - `Scene.deduplicated` is removed because it's one line and also mildly pointless, you can get the same effect with `Scene(list({g.identifier_hash: g for g in self.geometry.values()}.values())`)




Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fb3ca2e2..209a0dea 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -143,4 +143,5 @@ jobs:
           tag_name: ${{ steps.set_tag.outputs.tag_name }}
           release_name: Release ${{ steps.set_tag.outputs.tag_name }}
           draft: false
+          body: ${{ github.event.head_commit.message }}
           prerelease: false
diff --git a/Dockerfile b/Dockerfile
index 70c4b50a..f134a1bf 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -21,6 +21,7 @@ RUN python3.12 -m venv venv
 
 # So scripts installed from pip are in $PATH
 ENV PATH="/home/user/venv/bin:$PATH"
+ENV VIRTUAL_ENV="/home/user/venv"
 
 # Install helper script to PATH.
 COPY --chmod=755 docker/trimesh-setup /home/user/venv/bin
@@ -29,13 +30,6 @@ COPY --chmod=755 docker/trimesh-setup /home/user/venv/bin
 ## install things that need building
 FROM base AS build
 
-USER root
-# install wget for fetching wheels
-RUN apt-get update && \
-    apt-get install --no-install-recommends -qq -y wget ca-certificates && \
-    apt-get clean -y 
-USER user
-
 # copy in essential files
 COPY --chown=499 trimesh/ /home/user/trimesh
 COPY --chown=499 pyproject.toml /home/user/
@@ -43,10 +37,8 @@ COPY --chown=499 pyproject.toml /home/user/
 # install trimesh into the venv
 RUN pip install /home/user[easy]
 
-# install FCL, which currently has broken wheels on pypi
-RUN wget https://github.com/BerkeleyAutomation/python-fcl/releases/download/v0.7.0.7/python_fcl-0.7.0.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl && \
-    pip install python_fcl*.whl && \
-    rm python_fcl*.whl
+# install FCL which currently has broken wheels on PyPi
+RUN pip install https://github.com/BerkeleyAutomation/python-fcl/releases/download/v0.7.0.7/python_fcl-0.7.0.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
 
 ####################################
 ### Build output image most things should run on
diff --git a/docs/content/contributing.md b/docs/content/contributing.md
index a82e9f04..16b9ac2d 100644
--- a/docs/content/contributing.md
+++ b/docs/content/contributing.md
@@ -73,8 +73,8 @@ When you remove the embed and see the profile result you can then tweak the line
 ### Automatic Formatting
 Trimesh uses `ruff` for both linting and formatting which is configured in `pyproject.toml`, you can run with:
 ```
-ruff . --fix
-ruff format .
+ruff check --fix
+ruff format
 ```
 
 ## Docstrings
diff --git a/pyproject.toml b/pyproject.toml
index 7c3b08fa..d5cc5e60 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ requires = ["setuptools >= 61.0", "wheel"]
 [project]
 name = "trimesh"
 requires-python = ">=3.8"
-version = "4.4.8"
+version = "4.4.9"
 authors = [{name = "Michael Dawson-Haggerty", email = "mikedh@kerfed.com"}]
 license = {file = "LICENSE.md"}
 description = "Import, export, process, analyze and view triangular meshes."
diff --git a/trimesh/__init__.py b/trimesh/__init__.py
index 7a047208..00ebec5d 100644
--- a/trimesh/__init__.py
+++ b/trimesh/__init__.py
@@ -11,6 +11,7 @@ and analysis, in the style of the Polygon object in the Shapely library.
 # avoid a circular import in trimesh.base
 from . import (
     boolean,
+    bounds,
     caching,
     collision,
     comparison,
@@ -24,6 +25,7 @@ from . import (
     grouping,
     inertia,
     intersections,
+    nsphere,
     permutate,
     poses,
     primitives,
@@ -45,7 +47,13 @@ from .base import Trimesh
 from .constants import tol
 
 # loader functions
-from .exchange.load import available_formats, load, load_mesh, load_path, load_remote
+from .exchange.load import (
+    available_formats,
+    load,
+    load_mesh,
+    load_path,
+    load_remote,
+)
 
 # geometry objects
 from .parent import Geometry
@@ -118,6 +126,5 @@ __all__ = [
     "unitize",
     "units",
     "util",
-    "utilScene",
     "voxel",
 ]
diff --git a/trimesh/base.py b/trimesh/base.py
index 5e5ea49f..388d251a 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -1112,9 +1112,9 @@ class Trimesh(Geometry3D):
         self,
         merge_tex: Optional[bool] = None,
         merge_norm: Optional[bool] = None,
-        digits_vertex: Optional[bool] = None,
-        digits_norm: Optional[bool] = None,
-        digits_uv: Optional[bool] = None,
+        digits_vertex: Optional[Integer] = None,
+        digits_norm: Optional[Integer] = None,
+        digits_uv: Optional[Integer] = None,
     ) -> None:
         """
         Removes duplicate vertices grouped by position and
diff --git a/trimesh/bounds.py b/trimesh/bounds.py
index b86a9c24..08b48f4d 100644
--- a/trimesh/bounds.py
+++ b/trimesh/bounds.py
@@ -28,6 +28,9 @@ def oriented_bounds_2D(points, qhull_options="QbB"):
     """
     Find an oriented bounding box for an array of 2D points.
 
+    Details on qhull options:
+      http://www.qhull.org/html/qh-quick.htm#options
+
     Parameters
     ----------
     points : (n,2) float
diff --git a/trimesh/convex.py b/trimesh/convex.py
index 3e507b4c..e535aaf5 100644
--- a/trimesh/convex.py
+++ b/trimesh/convex.py
@@ -9,10 +9,14 @@ Convex is defined as:
 3) (of a polygon) having only interior angles measuring less than 180
 """
 
+from dataclasses import dataclass, fields
+
 import numpy as np
 
 from . import triangles, util
 from .constants import tol
+from .parent import Geometry3D
+from .typed import NDArray, Union
 
 try:
     from scipy.spatial import ConvexHull
@@ -27,30 +31,171 @@ except BaseException:
     QhullError = BaseException
 
 
-def convex_hull(obj, qhull_options="QbB Pp Qt", repair=True):
+@dataclass
+class QhullOptions:
+    """
+    A helper class for constructing correct Qhull option strings.
+    More details available at: http://www.qhull.org/html/qh-quick.htm#options
+
+    Currently only includes the boolean flag options, which is most of them.
+
+    Parameters
+    -----------
+    Qa
+      Allow input with fewer or more points than coordinates
+    Qc
+      Keep coplanar points with nearest facet
+    Qi
+      Keep interior points with nearest facet.
+    QJ
+      Joggled input to avoid precision problems
+    Qt
+      Triangulated output.
+    Qu
+      Compute upper hull for furthest-site Delaunay triangulation
+    Qw
+      Allow warnings about Qhull options
+    Qbb
+      Scale last coordinate to [0,m] for Delaunay
+    Qs
+      Search all points for the initial simplex
+    Qv
+      Test vertex neighbors for convexity
+    Qx
+      Exact pre-merges (allows coplanar facets)
+    Qz
+      Add a point-at-infinity for Delaunay triangulations
+    QbB
+      Scale input to fit the unit cube
+    QR0
+      Random rotation (n=seed, n=0 time, n=-1 time/no rotate)
+    Qg
+      only build good facets (needs 'QGn', 'QVn', or 'Pdk')
+    Pp
+      Do not print statistics about precision problems and remove
+      some of the warnings including the narrow hull warning.
+    """
+
+    Qa: bool = False
+    """ Allow input with fewer or more points than coordinates"""
+
+    Qc: bool = False
+    """ Keep coplanar points with nearest facet"""
+
+    Qi: bool = False
+    """ Keep interior points with nearest facet. """
+
+    QJ: bool = False
+    """ Joggled input to avoid precision problems """
+
+    Qt: bool = False
+    """ Triangulated output. """
+
+    Qu: bool = False
+    """ Compute upper hull for furthest-site Delaunay triangulation """
+
+    Qw: bool = False
+    """ Allow warnings about Qhull options """
+
+    # Precision handling
+    Qbb: bool = False
+    """ Scale last coordinate to [0,m] for Delaunay """
+
+    Qs: bool = False
+    """ Search all points for the initial simplex """
+
+    Qv: bool = False
+    """ Test vertex neighbors for convexity """
+
+    Qx: bool = False
+    """ Exact pre-merges (allows coplanar facets)  """
+
+    Qz: bool = False
+    """ Add a point-at-infinity for Delaunay triangulations """
+
+    QbB: bool = False
+    """ Scale input to fit the unit cube """
+
+    QR0: bool = False
+    """ Random rotation (n=seed, n=0 time, n=-1 time/no rotate) """
+
+    # Select facets
+    Qg: bool = False
+    """ Only build good facets (needs 'QGn', 'QVn', or 'Pdk') """
+
+    Pp: bool = False
+    """ Do not print statistics about precision problems and remove
+      some of the warnings including the narrow hull warning. """
+
+    # TODO : not included non-boolean options
+    # QBk: Optional[Floating] = None
+    # """ Scale coord[k] to upper bound of n (default 0.5) """
+
+    # Qbk: Optional[Floating] = None
+    # """ Scale coord[k] to low bound of n (default -0.5) """
+
+    # Qbk:0Bk:0
+    # """ drop dimension k from input """
+
+    # QGn
+    #    good facet if visible from point n, -n for not visible
+
+    # QVn
+    #    good facet if it includes point n, -n if not
+
+    def __str__(self) -> str:
+        """
+        Construct the `qhull_options` string used by `scipy.spatial`
+        objects and functions.
+
+        Returns
+        ----------
+        qhull_options
+          Can be passed to `scipy.spatial.[ConvexHull,Delaunay,Voronoi]`
+        """
+        return " ".join(f.name for f in fields(self) if getattr(self, f.name))
+
+
+QHULL_DEFAULT = QhullOptions(QbB=True, Pp=True, Qt=True)
+
+
+def convex_hull(
+    obj: Union[Geometry3D, NDArray],
+    qhull_options: Union[QhullOptions, str, None] = QHULL_DEFAULT,
+    repair: bool = True,
+) -> "trimesh.Trimesh":  # noqa: F821
     """
     Get a new Trimesh object representing the convex hull of the
     current mesh attempting to return a watertight mesh with correct
     normals.
 
-    Details on qhull options:
-      http://www.qhull.org/html/qh-quick.htm#options
-
     Arguments
     --------
-    obj : Trimesh, or (n,3) float
-      Mesh or cartesian points
-    qhull_options : str
+    obj
+      Mesh or `(n, 3)` points.
+    qhull_options
       Options to pass to qhull.
 
     Returns
     --------
-    convex : Trimesh
-      Mesh of convex hull
+    convex
+      Mesh of convex hull.
     """
+    # would be a circular import at the module level
     from .base import Trimesh
 
-    if isinstance(obj, Trimesh):
+    # compose the
+    if qhull_options is None:
+        qhull_str = None
+    elif isinstance(qhull_options, QhullOptions):
+        # use the __str__ method to compose this options string
+        qhull_str = str(qhull_options)
+    elif isinstance(qhull_options, str):
+        qhull_str = qhull_options
+    else:
+        raise TypeError(type(qhull_options))
+
+    if hasattr(obj, "vertices"):
         points = obj.vertices.view(np.ndarray)
     else:
         # will remove subclassing
@@ -59,7 +204,7 @@ def convex_hull(obj, qhull_options="QbB Pp Qt", repair=True):
             raise ValueError("Object must be Trimesh or (n,3) points!")
 
     try:
-        hull = ConvexHull(points, qhull_options=qhull_options)
+        hull = ConvexHull(points, qhull_options=qhull_str)
     except QhullError:
         util.log.debug("Failed to compute convex hull: retrying with `QJ`", exc_info=True)
         # try with "joggle" enabled
@@ -137,7 +282,7 @@ def convex_hull(obj, qhull_options="QbB Pp Qt", repair=True):
     # sometimes the QbB option will cause precision issues
     # so try the hull again without it and
     # check for qhull_options is None to avoid infinite recursion
-    if qhull_options is not None and not convex.is_winding_consistent:
+    if qhull_options is None and not convex.is_winding_consistent:
         return convex_hull(convex, qhull_options=None)
 
     return convex
@@ -219,6 +364,9 @@ def hull_points(obj, qhull_options="QbB Pp"):
     """
     Try to extract a convex set of points from multiple input formats.
 
+    Details on qhull options:
+      http://www.qhull.org/html/qh-quick.htm#options
+
     Parameters
     ---------
     obj: Trimesh object
diff --git a/trimesh/exchange/export.py b/trimesh/exchange/export.py
index 438d6be9..c2c64a15 100644
--- a/trimesh/exchange/export.py
+++ b/trimesh/exchange/export.py
@@ -276,9 +276,9 @@ def export_scene(scene, file_obj, file_type=None, resolver=None, **kwargs):
 
         data = svg_io.export_svg(scene, **kwargs)
     elif file_type == "ply":
-        data = _mesh_exporters["ply"](scene.dump(concatenate=True), **kwargs)
+        data = _mesh_exporters["ply"](scene.to_mesh(), **kwargs)
     elif file_type == "stl":
-        data = export_stl(scene.dump(concatenate=True), **kwargs)
+        data = export_stl(scene.to_mesh(), **kwargs)
     elif file_type == "3mf":
         data = _mesh_exporters["3mf"](scene, **kwargs)
     else:
diff --git a/trimesh/grouping.py b/trimesh/grouping.py
index d5cccfb2..07e30cb7 100644
--- a/trimesh/grouping.py
+++ b/trimesh/grouping.py
@@ -23,11 +23,11 @@ except BaseException as E:
 
 def merge_vertices(
     mesh,
-    merge_tex=None,
-    merge_norm=None,
-    digits_vertex=None,
-    digits_norm=None,
-    digits_uv=None,
+    merge_tex: Optional[bool] = None,
+    merge_norm: Optional[bool] = None,
+    digits_vertex: Optional[Integer] = None,
+    digits_norm: Optional[Integer] = None,
+    digits_uv: Optional[Integer] = None,
 ):
     """
     Removes duplicate vertices, grouped by position and
@@ -110,7 +110,7 @@ def merge_vertices(
     mesh.update_vertices(mask=mask, inverse=inverse)
 
 
-def group(values, min_len=0, max_len=np.inf):
+def group(values, min_len: Optional[Integer] = None, max_len: Optional[Integer] = None):
     """
     Return the indices of values that are identical
 
@@ -149,10 +149,20 @@ def group(values, min_len=0, max_len=np.inf):
         nondupe = values[1:] != values[:-1]
 
     dupe_idx = np.append(0, np.nonzero(nondupe)[0] + 1)
+
+    # start with a mask that marks everything as ok
+    dupe_ok = np.ones(len(dupe_idx), dtype=bool)
+
+    # calculate the length of each group from their index
     dupe_len = np.diff(np.concatenate((dupe_idx, [len(values)])))
-    dupe_ok = np.logical_and(
-        np.greater_equal(dupe_len, min_len), np.less_equal(dupe_len, max_len)
-    )
+
+    # cull by length if requested
+    if min_len is not None or max_len is not None:
+        if min_len is not None:
+            dupe_ok &= dupe_len >= min_len
+        if max_len is not None:
+            dupe_ok &= dupe_len <= max_len
+
     groups = [order[i : (i + j)] for i, j in zip(dupe_idx[dupe_ok], dupe_len[dupe_ok])]
     return groups
 
@@ -264,7 +274,9 @@ def float_to_int(data, digits: Optional[Integer] = None) -> NDArray[np.int64]:
     return np.round((data * 10**digits) - 1e-6).astype(np.int64)
 
 
-def unique_ordered(data, return_index=False, return_inverse=False):
+def unique_ordered(
+    data: ArrayLike, return_index: bool = False, return_inverse: bool = False
+):
     """
     Returns the same as np.unique, but ordered as per the
     first occurrence of the unique value in data.
@@ -306,7 +318,12 @@ def unique_ordered(data, return_index=False, return_inverse=False):
     return result
 
 
-def unique_bincount(values, minlength=0, return_inverse=False, return_counts=False):
+def unique_bincount(
+    values: ArrayLike,
+    minlength: Integer = 0,
+    return_inverse: bool = False,
+    return_counts: bool = False,
+):
     """
     For arrays of integers find unique values using bin counting.
     Roughly 10x faster for correct input than np.unique
@@ -372,7 +389,7 @@ def unique_bincount(values, minlength=0, return_inverse=False, return_counts=Fal
     return ret
 
 
-def merge_runs(data, digits=None):
+def merge_runs(data: ArrayLike, digits: Optional[Integer] = None):
     """
     Merge duplicate sequential values. This differs from unique_ordered
     in that values can occur in multiple places in the sequence, but
@@ -397,15 +414,25 @@ def merge_runs(data, digits=None):
     In [2]: trimesh.grouping.merge_runs(a)
     Out[2]: array([-1,  0,  1,  2,  0,  3,  4,  5,  6,  7,  8,  9])
     """
+    if digits is None:
+        epsilon = tol.merge
+    else:
+        epsilon = 10 ** (-digits)
+
     data = np.asanyarray(data)
     mask = np.zeros(len(data), dtype=bool)
     mask[0] = True
-    mask[1:] = np.abs(data[1:] - data[:-1]) > tol.merge
+    mask[1:] = np.abs(data[1:] - data[:-1]) > epsilon
 
     return data[mask]
 
 
-def unique_float(data, return_index=False, return_inverse=False, digits=None):
+def unique_float(
+    data,
+    return_index: bool = False,
+    return_inverse: bool = False,
+    digits: Optional[Integer] = None,
+):
     """
     Identical to the numpy.unique command, except evaluates floating point
     numbers, using a specified number of digits.
diff --git a/trimesh/interfaces/gmsh.py b/trimesh/interfaces/gmsh.py
index 4455b73f..d70f6bdd 100644
--- a/trimesh/interfaces/gmsh.py
+++ b/trimesh/interfaces/gmsh.py
@@ -23,7 +23,7 @@ _warning = " ".join(
 )
 
 
-def load_gmsh(file_name, gmsh_args=None):
+def load_gmsh(file_name, gmsh_args=None, interruptible=True):
     """
     Returns a surface mesh from CAD model in Open Cascade
     Breap (.brep), Step (.stp or .step) and Iges formats
@@ -45,6 +45,9 @@ def load_gmsh(file_name, gmsh_args=None):
       gmsh.option.setNumber
     max_element : float or None
       Maximum length of an element in the volume mesh
+    interruptible : bool
+      Allows load_gmsh to run outside of the main thread if False,
+      default behaviour if set to True. Added in 4.12.0
 
     Returns
     ------------
@@ -115,7 +118,7 @@ def load_gmsh(file_name, gmsh_args=None):
         log.debug("gmsh unexpected", exc_info=True)
 
     if not init:
-        gmsh.initialize()
+        gmsh.initialize(interruptible=interruptible)
 
     gmsh.option.setNumber("General.Terminal", 1)
     gmsh.model.add("Surface_Mesh_Generation")
diff --git a/trimesh/parent.py b/trimesh/parent.py
index 6fde5ea3..6b0e50bb 100644
--- a/trimesh/parent.py
+++ b/trimesh/parent.py
@@ -13,7 +13,7 @@ from . import bounds, caching
 from . import transformations as tf
 from .caching import cache_decorator
 from .constants import tol
-from .typed import Any, ArrayLike, Dict, Optional
+from .typed import Any, ArrayLike, Dict, NDArray, Optional
 from .util import ABC
 
 
@@ -31,12 +31,12 @@ class Geometry(ABC):
 
     @property
     @abc.abstractmethod
-    def bounds(self):
+    def bounds(self) -> NDArray[np.float64]:
         pass
 
     @property
     @abc.abstractmethod
-    def extents(self):
+    def extents(self) -> NDArray[np.float64]:
         pass
 
     @abc.abstractmethod
@@ -57,7 +57,7 @@ class Geometry(ABC):
         hash : int
           Hash of current graph and geometry.
         """
-        return self._data.__hash__()
+        return self._data.__hash__()  # type: ignore
 
     @abc.abstractmethod
     def copy(self):
@@ -103,7 +103,7 @@ class Geometry(ABC):
             elements.append(f"name=`{display}`")
         return "<trimesh.{}({})>".format(type(self).__name__, ", ".join(elements))
 
-    def apply_translation(self, translation):
+    def apply_translation(self, translation: ArrayLike):
         """
         Translate the current mesh.
 
@@ -230,8 +230,7 @@ class Geometry3D(Geometry):
         # translate to center of axis aligned bounds
         transform[:3, 3] = self.bounds.mean(axis=0)
 
-        aabb = primitives.Box(transform=transform, extents=self.extents, mutable=False)
-        return aabb
+        return primitives.Box(transform=transform, extents=self.extents, mutable=False)
 
     @caching.cache_decorator
     def bounding_box_oriented(self):
@@ -271,8 +270,7 @@ class Geometry3D(Geometry):
         from . import nsphere, primitives
 
         center, radius = nsphere.minimum_nsphere(self)
-        minball = primitives.Sphere(center=center, radius=radius, mutable=False)
-        return minball
+        return primitives.Sphere(center=center, radius=radius, mutable=False)
 
     @caching.cache_decorator
     def bounding_cylinder(self):
@@ -287,8 +285,7 @@ class Geometry3D(Geometry):
         from . import bounds, primitives
 
         kwargs = bounds.minimum_cylinder(self)
-        mincyl = primitives.Cylinder(mutable=False, **kwargs)
-        return mincyl
+        return primitives.Cylinder(mutable=False, **kwargs)
 
     @caching.cache_decorator
     def bounding_primitive(self):
@@ -310,8 +307,7 @@ class Geometry3D(Geometry):
             self.bounding_cylinder,
         ]
         volume_min = np.argmin([i.volume for i in options])
-        bounding_primitive = options[volume_min]
-        return bounding_primitive
+        return options[volume_min]
 
     def apply_obb(self, **kwargs):
         """
diff --git a/trimesh/path/repair.py b/trimesh/path/repair.py
index 231b75b7..568c9fb6 100644
--- a/trimesh/path/repair.py
+++ b/trimesh/path/repair.py
@@ -14,24 +14,13 @@ from . import segments
 
 def fill_gaps(path, distance=0.025):
     """
-    For 3D line segments defined by two points, turn
-    them in to an origin defined as the closest point along
-    the line to the zero origin as well as a direction vector
-    and start and end parameter.
+    Find vertices without degree 2 and try to connect to
+    other vertices. Operations are done in-place.
 
     Parameters
     ------------
-    segments : (n, 2, 3) float
+    segments : trimesh.path.Path2D
        Line segments defined by start and end points
-
-    Returns
-    --------------
-    origins : (n, 3) float
-       Point on line closest to [0, 0, 0]
-    vectors : (n, 3) float
-       Unit line directions
-    parameters : (n, 2) float
-       Start and end distance pairs for each line
     """
 
     # find any vertex without degree 2 (connected to two things)
diff --git a/trimesh/registration.py b/trimesh/registration.py
index 5eef31af..15dba178 100644
--- a/trimesh/registration.py
+++ b/trimesh/registration.py
@@ -12,6 +12,7 @@ from .geometry import weighted_vertex_normals
 from .points import PointCloud, plane_fit
 from .transformations import transform_points
 from .triangles import angles, cross, normals
+from .typed import ArrayLike, Integer, Optional
 
 try:
     import scipy.sparse as sparse
@@ -26,7 +27,13 @@ except BaseException as E:
 
 
 def mesh_other(
-    mesh, other, samples=500, scale=False, icp_first=10, icp_final=50, **kwargs
+    mesh,
+    other,
+    samples: Integer = 500,
+    scale: bool = False,
+    icp_first: Integer = 10,
+    icp_final: Integer = 50,
+    **kwargs,
 ):
     """
     Align a mesh with another mesh or a PointCloud using
@@ -185,14 +192,25 @@ def mesh_other(
 
 
 def procrustes(
-    a, b, weights=None, reflection=True, translation=True, scale=True, return_cost=True
+    a: ArrayLike,
+    b: ArrayLike,
+    weights: Optional[ArrayLike] = None,
+    reflection: bool = True,
+    translation: bool = True,
+    scale: bool = True,
+    return_cost: bool = True,
 ):
     """
-    Perform Procrustes' analysis subject to constraints. Finds the
-    transformation T mapping a to b which minimizes the square sum
-    distances between Ta and b, also called the cost. Optionally
-    specify different weights for the points in a to minimize the
-    weighted square sum distances between Ta and b. This can
+    Perform Procrustes' analysis to quickly align two corresponding
+    point clouds subject to constraints. This is much cheaper than
+    any other registration method but only applies if the two inputs
+    correspond in order.
+
+    Finds the transformation T mapping a to b which minimizes the
+    square sum distances between Ta and b, also called the cost.
+
+    Optionally specify different weights for the points in a to minimize
+    the weighted square sum distances between Ta and b, which can
     improve transformation robustness on noisy data if the points'
     probability distribution is known.
 
@@ -207,7 +225,7 @@ def procrustes(
     reflection : bool
       If the transformation is allowed reflections
     translation : bool
-      If the transformation is allowed translations
+      If the transformation is allowed translation and rotation.
     scale : bool
       If the transformation is allowed scaling
     return_cost : bool
@@ -291,6 +309,7 @@ def procrustes(
 
     if return_cost:
         transformed = transform_points(a, matrix)
+        # return the mean euclidean distance squared as the cost
         cost = ((b - transformed) ** 2).mean()
         return matrix, transformed, cost
     else:
diff --git a/trimesh/scene/scene.py b/trimesh/scene/scene.py
index bbc31a9a..a777989c 100644
--- a/trimesh/scene/scene.py
+++ b/trimesh/scene/scene.py
@@ -1,5 +1,6 @@
 import collections
 import uuid
+import warnings
 
 import numpy as np
 
@@ -7,9 +8,13 @@ from .. import caching, convex, grouping, inertia, transformations, units, util
 from ..constants import log
 from ..exchange import export
 from ..parent import Geometry, Geometry3D
+from ..registration import procrustes
 from ..typed import (
     ArrayLike,
     Dict,
+    Floating,
+    Integer,
+    Iterable,
     List,
     NDArray,
     Optional,
@@ -24,9 +29,7 @@ from . import cameras, lighting
 from .transforms import SceneGraph
 
 # the types of objects we can create a scene from
-GeometryInput = Union[
-    Geometry, Sequence[Geometry], NDArray[Geometry], Dict[str, Geometry]
-]
+GeometryInput = Union[Geometry, Iterable[Geometry], Dict[str, Geometry]]
 
 
 class Scene(Geometry3D):
@@ -164,7 +167,7 @@ class Scene(Geometry3D):
                     transform=transform,
                     metadata=metadata,
                 )
-                for value in geometry
+                for value in geometry  # type: ignore
             ]
         elif isinstance(geometry, dict):
             # if someone passed us a dict of geometry
@@ -261,6 +264,36 @@ class Scene(Geometry3D):
             if util.is_instance_named(geometry, "Trimesh"):
                 geometry.visual = ColorVisuals(mesh=geometry)
 
+    def simplify_quadric_decimation(
+        self,
+        percent: Optional[Floating] = None,
+        face_count: Optional[Integer] = None,
+        aggression: Optional[Integer] = None,
+    ) -> None:
+        """
+        Apply in-place `mesh.simplify_quadric_decimation` to any meshes
+        in the scene.
+
+        Parameters
+        -----------
+        percent
+          A number between 0.0 and 1.0 for how much
+        face_count
+          Target number of faces desired in the resulting mesh.
+        agression
+          An integer between `0` and `10`, the scale being roughly
+          `0` is "slow and good" and `10` being "fast and bad."
+
+        """
+        # save the updates for after the loop
+        updates = {}
+        for k, v in self.geometry.items():
+            if hasattr(v, "simplify_quadric_decimation"):
+                updates[k] = v.simplify_quadric_decimation(
+                    percent=percent, face_count=face_count, aggression=aggression
+                )
+        self.geometry.update(updates)
+
     def __hash__(self) -> int:
         """
         Return information about scene which is hashable.
@@ -632,6 +665,8 @@ class Scene(Geometry3D):
 
     def deduplicated(self) -> "Scene":
         """
+        DEPRECATED: REMOVAL JANUARY 2025, this is one line and not that useful.
+
         Return a new scene where each unique geometry is only
         included once and transforms are discarded.
 
@@ -640,16 +675,46 @@ class Scene(Geometry3D):
         dedupe : Scene
           One copy of each unique geometry from scene
         """
-        # collect geometry
-        geometry = {}
-        # loop through groups of identical nodes
-        for group in self.duplicate_nodes:
-            # get the name of the geometry
-            name = self.graph[group[0]][1]
-            # collect our unique collection of geometry
-            geometry[name] = self.geometry[name]
 
-        return Scene(geometry)
+        warnings.warn(
+            "DEPRECATED: REMOVAL JANUARY 2025, this is one line and not that useful.",
+            category=DeprecationWarning,
+            stacklevel=2,
+        )
+
+        # keying by `identifier_hash` will mean every geometry is unique
+        return Scene(
+            list({g.identifier_hash: g for g in self.geometry.values()}.values())
+        )
+
+    def reconstruct_instances(self, cost_threshold: Floating = 1e-5) -> "Scene":
+        """
+        If a scene has been "baked" with meshes it means that
+        the duplicate nodes have *corresponding vertices* but are
+        rigidly transformed to different places.
+
+        This means the problem of finding ab instance transform can
+        use the `procrustes` analysis which is *very* fast relative
+        to more complicated registration problems that require ICP
+        and nearest-point-on-surface calculations.
+
+        TODO : construct a parent non-geometry node for containing every group.
+
+        Parameters
+        ----------
+        scene
+        The scene to handle.
+        cost_threshold
+        The maximum value for `procrustes` cost which is "squared mean
+        vertex distance between pair". If the fit is above this value
+        the instance will be left even if it is a duplicate.
+
+        Returns
+        ---------
+        dedupe
+        A copy of the scene de-duplicated as much as possible.
+        """
+        return reconstruct_instances(self, cost_threshold=cost_threshold)
 
     def set_camera(
         self, angles=None, distance=None, center=None, resolution=None, fov=None
@@ -846,20 +911,25 @@ class Scene(Geometry3D):
         )
         self.graph.base_frame = new_base
 
-    def dump(self, concatenate: bool = False) -> Union[Geometry, List[Geometry]]:
+    def dump(self, concatenate: bool = False) -> List[Geometry]:
         """
-        Append all meshes in scene freezing transforms.
+        Get a list of every geometry moved to it's instance position,
+        i.e. freezing or "baking" transforms.
 
         Parameters
         ------------
         concatenate
-          If True, concatenate results into single mesh
+          KWARG IS DEPRECATED FOR REMOVAL APRIL 2025
+          Concatenate results into single geometry.
+          This keyword argument will make the type hint incorrect and
+          you should replace `Scene.dump(concatenate=True)` with:
+            - `Scene.to_geometry()` for a Trimesh, Path2D or Path3D
+            - `Scene.to_mesh()` for only `Trimesh` components.
 
         Returns
         ----------
-        dumped : (n,) Trimesh, Path2D, Path3D, PointCloud
-          Depending on what the scene contains. If `concatenate`
-          then some geometry may be dropped if it doesn't match.
+        dumped
+          Copies of `Scene.geometry` transformed to their instance position.
         """
 
         result = []
@@ -889,11 +959,46 @@ class Scene(Geometry3D):
             result.append(current)
 
         if concatenate:
+            warnings.warn(
+                "`Scene.dump(concatenate=True)` DEPRECATED FOR REMOVAL APRIL 2025: replace with `Scene.to_geometry()`",
+                category=DeprecationWarning,
+                stacklevel=2,
+            )
             # if scene has mixed geometry this may drop some of it
-            return util.concatenate(result)
+            return util.concatenate(result)  # type: ignore
 
         return result
 
+    def to_mesh(self) -> "trimesh.Trimesh":  # noqa: F821
+        """
+        Concatenate every mesh instances in the scene into a single mesh,
+        applying transforms and "baking" the result. Will drop any geometry
+        in the scene that is not a `Trimesh` object.
+
+        Returns
+        ----------
+        mesh
+          All meshes in the scene concatenated into one.
+        """
+        from ..base import Trimesh
+
+        # concatenate only meshes
+        return util.concatenate([d for d in self.dump() if isinstance(d, Trimesh)])
+
+    def to_geometry(self) -> Geometry:
+        """
+        Concatenate geometry in the scene into a single like-typed geometry,
+        applying the transforms and "baking" the result. May drop geometry
+        if the scene has mixed geometry.
+
+        Returns
+        ---------
+        concat
+          Either a Trimesh, Path2D, or Path3D depending on what is in the scene.
+        """
+        # concatenate everything and return the most-occurring type.
+        return util.concatenate(self.dump())
+
     def subscene(self, node: str) -> "Scene":
         """
         Get part of a scene that succeeds a specified node.
@@ -934,7 +1039,7 @@ class Scene(Geometry3D):
         hull : trimesh.Trimesh
           Trimesh object which is a convex hull of all meshes in scene
         """
-        points = util.vstack_empty([m.vertices for m in self.dump()])
+        points = util.vstack_empty([m.vertices for m in self.dump()])  # type: ignore
         return convex.convex_hull(points)
 
     def export(self, file_obj=None, file_type=None, **kwargs):
@@ -1046,11 +1151,8 @@ class Scene(Geometry3D):
         # find the float conversion
         scale = units.unit_conversion(current=current, desired=desired)
 
-        # exit early if our current units are the same as desired units
-        if np.isclose(scale, 1.0):
-            result = self.copy()
-        else:
-            result = self.scaled(scale=scale)
+        # apply scaling factor or exit early if scale ~= 1.0
+        result = self.scaled(scale=scale)
 
         # apply the units to every geometry of the scaled result
         result.units = desired
@@ -1096,7 +1198,7 @@ class Scene(Geometry3D):
             T_new[:3, 3] += offset
             self.graph[node_name] = T_new
 
-    def scaled(self, scale: Union[float, ArrayLike]) -> "Scene":
+    def scaled(self, scale: Union[Floating, ArrayLike]) -> "Scene":
         """
         Return a copy of the current scene, with meshes and scene
         transforms scaled to the requested factor.
@@ -1111,6 +1213,12 @@ class Scene(Geometry3D):
         scaled : trimesh.Scene
           A copy of the current scene but scaled
         """
+        result = self.copy()
+
+        # a scale of 1.0 is a no-op
+        if np.allclose(scale, 1.0):
+            return result
+
         # convert 2D geometries to 3D for 3D scaling factors
         scale_is_3D = isinstance(scale, (list, tuple, np.ndarray)) and len(scale) == 3
 
@@ -1122,7 +1230,6 @@ class Scene(Geometry3D):
             scale = float(scale)
 
         # result is a copy
-        result = self.copy()
 
         if scale_is_3D:
             # Copy all geometries that appear multiple times in the scene,
@@ -1155,7 +1262,6 @@ class Scene(Geometry3D):
                 if result.geometry[geom_name].vertices.shape[1] == 2:
                     result.geometry[geom_name] = result.geometry[geom_name].to_3D()
 
-            # Scale all geometries by un-doing their local rotations first
             for key in result.graph.nodes_geometry:
                 T, geom_name = result.graph.get(key)
                 # transform from graph should be read-only
@@ -1222,6 +1328,10 @@ class Scene(Geometry3D):
                     result.graph.update(
                         frame_to=node, matrix=transform, geometry=geometry
                     )
+
+        # remove camera from copied
+        result._camera = None
+
         return result
 
     def copy(self) -> "Scene":
@@ -1462,3 +1572,74 @@ def append_scenes(iterable, common=None, base_frame="world"):
     result.geometry.update(geometry)
 
     return result
+
+
+def reconstruct_instances(scene: Scene, cost_threshold: Floating = 1e-6) -> Scene:
+    """
+    If a scene has been "baked" with meshes it means that
+    the duplicate nodes have *corresponding vertices* but are
+    rigidly transformed to different places.
+
+    This means the problem of finding ab instance transform can
+    use the `procrustes` analysis which is *very* fast relative
+    to more complicated registration problems that require ICP
+    and nearest-point-on-surface calculations.
+
+    TODO : construct a parent non-geometry node for containing every group.
+
+    Parameters
+    ----------
+    scene
+      The scene to handle.
+    cost_threshold
+      The maximum value for `procrustes` cost which is "squared mean
+      vertex distance between pair". If the fit is above this value
+      the instance will be left even if it is a duplicate.
+
+    Returns
+    ---------
+    dedupe
+      A copy of the scene de-duplicated as much as possible.
+    """
+    # start with the original scene graph and modify in-loop
+    graph = scene.graph.copy()
+
+    for group in scene.duplicate_nodes:
+        # not sure if this ever includes
+        if len(group) < 2:
+            continue
+
+        # we are going to use one of the geometries and try to register the others to it
+        node_base = group[0]
+        # get the geometry name for this base node
+        _, geom_base = scene.graph[node_base]
+        # get the vertices of the base model
+        base: NDArray = scene.geometry[geom_base].vertices.view(np.ndarray)
+
+        for node in group[1:]:
+            # the original pose of this node in the scene
+            node_mat, node_geom = scene.graph[node]
+            # procrustes matches corresponding point arrays very quickly
+            # but we have to make sure that they actual correspond in shape
+            node_vertices = scene.geometry[node_geom].vertices.view(np.ndarray)
+
+            # procrustes only works on corresponding point clouds!
+            if node_vertices.shape != base.shape:
+                continue
+
+            # solve for a pose moving this instance into position
+            matrix, _p, cost = procrustes(
+                base, node_vertices, translation=True, scale=False, reflection=False
+            )
+            if cost < cost_threshold:
+                # add the transform we found
+                graph.update(node, matrix=np.dot(node_mat, matrix), geometry=geom_base)
+
+    # get from the new graph which geometry ends up with a reference
+    referenced = set(graph.geometry_nodes.keys())
+
+    # return a scene with the de-duplicated graph and a copy of any geometry
+    return Scene(
+        geometry={k: v.copy() for k, v in scene.geometry.items() if k in referenced},
+        graph=graph,
+    )
diff --git a/trimesh/scene/transforms.py b/trimesh/scene/transforms.py
index 7b006c3a..5b2ca9d8 100644
--- a/trimesh/scene/transforms.py
+++ b/trimesh/scene/transforms.py
@@ -6,7 +6,7 @@ import numpy as np
 from .. import caching, util
 from ..caching import hash_fast
 from ..transformations import fix_rigid, quaternion_matrix, rotation_matrix
-from ..typed import Sequence, Union
+from ..typed import ArrayLike, Hashable, NDArray, Optional, Sequence, Tuple, Union
 
 # we compare to identity a lot
 _identity = np.eye(4)
@@ -92,7 +92,9 @@ class SceneGraph:
         if "geometry" in kwargs:
             self.transforms.node_data[frame_to]["geometry"] = kwargs["geometry"]
 
-    def get(self, frame_to, frame_from=None):
+    def get(
+        self, frame_to: Hashable, frame_from: Optional[Hashable] = None
+    ) -> Tuple[NDArray[np.float64], Optional[Hashable]]:
         """
         Get the transform from one frame to another.
 
@@ -108,6 +110,8 @@ class SceneGraph:
         ----------
         transform : (4, 4) float
           Homogeneous transformation matrix
+        geometry
+          The name of the geometry if it exists
 
         Raises
         -----------
@@ -501,14 +505,16 @@ class SceneGraph:
         self._cache.cache.pop("nodes_geometry", None)
         self.transforms._hash = None
 
-    def __contains__(self, key):
+    def __contains__(self, key: Hashable) -> bool:
         return key in self.transforms.node_data
 
-    def __getitem__(self, key):
+    def __getitem__(
+        self, key: Hashable
+    ) -> Tuple[NDArray[np.float64], Optional[Hashable]]:
         return self.get(key)
 
-    def __setitem__(self, key, value):
-        value = np.asanyarray(value)
+    def __setitem__(self, key: Hashable, value: ArrayLike):
+        value = np.asanyarray(value, dtype=np.float64)
         if value.shape != (4, 4):
             raise ValueError("Matrix must be specified!")
         return self.update(key, matrix=value)
diff --git a/trimesh/typed.py b/trimesh/typed.py
index 67289b2d..b27698b5 100644
--- a/trimesh/typed.py
+++ b/trimesh/typed.py
@@ -20,9 +20,9 @@ if version_info >= (3, 9):
     List = list
     Tuple = tuple
     Dict = dict
-    from collections.abc import Callable, Iterable, Mapping, Sequence
+    from collections.abc import Callable, Hashable, Iterable, Mapping, Sequence
 else:
-    from typing import Callable, Dict, Iterable, List, Mapping, Sequence, Tuple
+    from typing import Callable, Dict, Hashable, Iterable, List, Mapping, Sequence, Tuple
 
 # most loader routes take `file_obj` which can either be
 # a file-like object or a file path, or sometimes a dict
@@ -42,8 +42,9 @@ Integer = Union[int, integer, unsignedinteger]
 # > isinstance(np.ones(1, dtype=np.float32)[0], float) # False
 Floating = Union[float, floating]
 
-# Many arguments take "any valid number."
-Number = Union[float, floating, Integer]
+# Many arguments take "any valid number" and don't care if it
+# is an integer or a floating point input.
+Number = Union[Floating, Integer]
 
 __all__ = [
     "IO",
@@ -65,4 +66,5 @@ __all__ = [
     "int64",
     "Mapping",
     "Callable",
+    "Hashable",
 ]
diff --git a/trimesh/util.py b/trimesh/util.py
index 309a77b7..0ff69b8d 100644
--- a/trimesh/util.py
+++ b/trimesh/util.py
@@ -26,6 +26,7 @@ import zipfile
 # for type checking
 from collections.abc import Mapping
 from io import BytesIO, StringIO
+from typing import Union
 
 import numpy as np
 
@@ -1411,7 +1412,9 @@ def type_named(obj, name):
     raise ValueError("Unable to extract class of name " + name)
 
 
-def concatenate(a, b=None):
+def concatenate(
+    a, b=None
+) -> Union["trimesh.Trimesh", "trimesh.path.Path2D", "trimesh.path.Path3D"]:  # noqa: F821
     """
     Concatenate two or more meshes.
 


Test patch:
diff --git a/tests/test_camera.py b/tests/test_camera.py
index 98ea3ecb..1d6e5e2b 100644
--- a/tests/test_camera.py
+++ b/tests/test_camera.py
@@ -116,6 +116,16 @@ class CameraTests(g.unittest.TestCase):
             assert all(rid.min(axis=0) == 0)
             assert all(rid.max(axis=0) == current - 1)
 
+    def test_scaled_copy(self):
+        s = g.get_mesh("cycloidal.3DXML")
+
+        s.units = "mm"
+        assert s.camera_transform.shape == (4, 4)
+
+        # the camera node should have been removed on copy
+        b = s.convert_units("m")
+        assert b.camera_transform.shape == (4, 4)
+
 
 if __name__ == "__main__":
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_gltf.py b/tests/test_gltf.py
index 10207f89..c43a5e9e 100644
--- a/tests/test_gltf.py
+++ b/tests/test_gltf.py
@@ -743,7 +743,7 @@ class GLTFTest(g.unittest.TestCase):
     def test_same_name(self):
         s = g.get_mesh("TestScene.gltf")
         # hardcode correct bounds to check against
-        bounds = s.dump(concatenate=True).bounds
+        bounds = s.to_mesh().bounds
 
         # icosahedrons have two primitives each
         g.log.debug(len(s.geometry), len(s.graph.nodes_geometry))
diff --git a/tests/test_inertia.py b/tests/test_inertia.py
index 86797200..e184c22d 100644
--- a/tests/test_inertia.py
+++ b/tests/test_inertia.py
@@ -402,7 +402,7 @@ class InertiaTest(g.unittest.TestCase):
         s._cache.clear()
 
         with g.Profiler() as P:
-            ms = s.dump(concatenate=True)
+            ms = s.to_mesh()
             total_dump = ms.moment_inertia
         g.log.debug(P.output_text())
 
diff --git a/tests/test_scene.py b/tests/test_scene.py
index dd6f5635..e9e78343 100644
--- a/tests/test_scene.py
+++ b/tests/test_scene.py
@@ -461,7 +461,7 @@ class SceneTests(g.unittest.TestCase):
         m = g.get_mesh("cycloidal.3DXML")
         assert isinstance(m, g.trimesh.Scene)
 
-        dump = m.dump(concatenate=True)
+        dump = m.to_mesh()
         assert isinstance(dump, g.trimesh.Trimesh)
 
         # scene bounds should exactly match mesh bounds
@@ -475,7 +475,7 @@ class SceneTests(g.unittest.TestCase):
             ]
         )
 
-        dump = scene.dump(concatenate=True)
+        dump = scene.to_mesh()
         assert isinstance(dump, g.trimesh.Trimesh)
 
     def test_append_scenes(self):
@@ -493,7 +493,7 @@ class SceneTests(g.unittest.TestCase):
         a = g.trimesh.Scene(
             [g.trimesh.primitives.Sphere(center=[5, 5, 5]), g.trimesh.primitives.Box()]
         )
-        c = a.dump(concatenate=True)
+        c = a.to_mesh()
         assert isinstance(c, g.trimesh.Trimesh)
         assert g.np.allclose(c.bounds, a.bounds)
 
@@ -502,7 +502,7 @@ class SceneTests(g.unittest.TestCase):
 
         # scene 2D
         scene_2D = g.trimesh.Scene(g.get_mesh("2D/250_cycloidal.DXF").split())
-        concat = scene_2D.dump(concatenate=True)
+        concat = scene_2D.to_geometry()
         assert isinstance(concat, g.trimesh.path.Path2D)
 
         dump = scene_2D.dump(concatenate=False)
@@ -518,7 +518,7 @@ class SceneTests(g.unittest.TestCase):
         assert len(dump) >= 5
         assert all(isinstance(i, g.trimesh.path.Path3D) for i in dump)
 
-        concat = scene_3D.dump(concatenate=True)
+        concat = scene_3D.to_geometry()
         assert isinstance(concat, g.trimesh.path.Path3D)
 
         mixed = list(scene_2D.geometry.values())
@@ -528,7 +528,7 @@ class SceneTests(g.unittest.TestCase):
         dump = scene_mixed.dump(concatenate=False)
         assert len(dump) == len(mixed)
 
-        concat = scene_mixed.dump(concatenate=True)
+        concat = scene_mixed.to_geometry()
         assert isinstance(concat, g.trimesh.path.Path3D)
 
 
diff --git a/tests/test_scenegraph.py b/tests/test_scenegraph.py
index 03dc9ec3..313324ff 100644
--- a/tests/test_scenegraph.py
+++ b/tests/test_scenegraph.py
@@ -142,7 +142,7 @@ class GraphTests(g.unittest.TestCase):
         # copy the original bounds of the scene's convex hull
         b = scene.convex_hull.bounds.tolist()
         # dump it into a single mesh
-        m = scene.dump(concatenate=True)
+        m = scene.to_mesh()
 
         # mesh bounds should match exactly
         assert g.np.allclose(m.bounds, b)
@@ -160,6 +160,18 @@ class GraphTests(g.unittest.TestCase):
         # should have moved from original position
         assert not g.np.allclose(m.convex_hull.bounds, b)
 
+    def test_simplify(self):
+        if not g.trimesh.util.has_module("fast_simplification"):
+            return
+
+        # get a scene graph
+        scene: g.trimesh.Scene = g.get_mesh("cycloidal.3DXML")
+
+        original = scene.to_mesh()
+
+        scene.simplify_quadric_decimation(percent=0.0, aggression=0)
+        assert len(scene.to_mesh().vertices) < len(original.vertices)
+
     def test_reverse(self):
         tf = g.trimesh.transformations
 
@@ -298,6 +310,24 @@ class GraphTests(g.unittest.TestCase):
         s.apply_translation(-s.bounds[0])
         assert g.np.allclose(s.bounds[0], 0)
 
+    def test_reconstruct(self):
+        original = g.get_mesh("cycloidal.3DXML")
+        assert isinstance(original, g.trimesh.Scene)
+
+        # get the scene as "baked" meshes with no scene graph
+        dupe = g.trimesh.Scene(original.dump())
+        assert len(dupe.geometry) > len(original.geometry)
+
+        with g.Profiler() as P:
+            # reconstruct the instancing using `duplicate_nodes` and `procrustes`
+            rec = dupe.reconstruct_instances()
+        g.log.info(P.output_text())
+
+        assert len(rec.graph.nodes_geometry) == len(original.graph.nodes_geometry)
+        assert len(rec.geometry) == len(original.geometry)
+        assert g.np.allclose(rec.extents, original.extents, rtol=1e-8)
+        assert g.np.allclose(rec.center_mass, original.center_mass, rtol=1e-8)
+
 
 if __name__ == "__main__":
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_voxel.py b/tests/test_voxel.py
index db389363..79a4bb52 100644
--- a/tests/test_voxel.py
+++ b/tests/test_voxel.py
@@ -163,7 +163,7 @@ class VoxelGridTest(g.unittest.TestCase):
         voxel = g.trimesh.voxel
 
         pitch = 0.1
-        origin = (0, 0, 1)
+        origin = [0, 0, 1]
 
         matrix = g.np.eye(9, dtype=bool).reshape((-1, 3, 3))
         centers = g.trimesh.voxel.ops.matrix_to_points(