Repository: fishbotics/urchin (language: python)

Issue:
Adding "lazy" save case when no meshes were ever loaded
I encountered permission issues on mesh files when calling the `save()` method when no meshes were ever loaded in my code (using the `lazy_load` argument). 

The problem was fixed by adding a check in the `Mesh` class `to_xml()` method, and works like a charm on my side :)

Thank you for maintaining urdfpy alive !


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml
new file mode 100644
index 0000000..54eaf0e
--- /dev/null
+++ b/.github/workflows/publish-to-pypi.yml
@@ -0,0 +1,36 @@
+name: Publish Python 🐍 distributions 📦 to PyPI
+
+on: push
+
+jobs:
+  build-n-publish:
+    name: Build and publish Python 🐍 distributions 📦 to PyPI
+    runs-on: ubuntu-latest
+    
+    steps:
+    - uses: actions/checkout@v3
+    - name: Set up Python
+      uses: actions/setup-python@v4
+      with:
+        python-version: "3.x"
+        
+    - name: Install pypa/build
+      run: >-
+        python -m
+        pip install
+        build
+        --user
+    - name: Build a binary wheel and a source tarball
+      run: >-
+        python -m
+        build
+        --sdist
+        --wheel
+        --outdir dist/
+        .
+
+    - name: Publish distribution 📦 to PyPI
+      if: startsWith(github.ref, 'refs/tags')
+      uses: pypa/gh-action-pypi-publish@release/v1
+      with:
+        password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.gitignore b/.gitignore
index c538c91..7fc58fa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -106,3 +106,6 @@ venv.bak/
 
 # mypy
 .mypy_cache/
+
+# PyCharm
+.idea/
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 285fa8d..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-language: python
-sudo: required
-dist: xenial
-
-python:
-- '3.5'
-- '3.6'
-- '3.7'
-
-install:
-- pip install .
-- pip install pytest pytest-cov coveralls
-
-script:
-- pytest --cov=urdfpy tests
-
-after_success:
-- coveralls || true
-
-deploy:
-  provider: pypi
-  skip_existing: true
-  user: mmatl
-  on:
-    tags: true
-    branch: master
-  password:
-    secure: UQM21roQPjm82x9h1seze9NlkDWEubzrmru72+bf89oOW7l2ZFhJANdAFO/tLH/vQL6/I8/SOpQn5U/d74h6yhsgoNAYioA0U6QRKNJMLnX2vJIucK0gdalEeTj9Pjiz6a9hVZsrMVjOQoTxe9vU/v2CZ2N+4WkFsXIRNHU7jg+MIprUbAew+ASpSdM5fM404CQFgqbiLK8J3HctV/onG2XBYAS9psYnmc0KNHaw4bqJGZcl1qH+A04a+B0zDZQy02WM7gd2FWycD+0grV2ytevKfV8fEHaUEyzXZq7bhdyoA4CuwIES2DVUZvfRYdDzKqCfJmTsWgpbbOZExuffnN80FaqbiN4feIjLaefVm0Eu4xNNBjG+fgoajqydBDrEsLCML+7OOdkd6RA6vW7ol1Jjb+4G27ZvCe5gwKoMPckDY8FCgMau9mzsr+wLZ9L3WoyaEecTTapFeE/FZOo6CkhHFPRwNAaSWvni6RHb7GVazkbUkAvPH+SyjtxTehoqY1j+8vjHx/VjR4q6ttjk8cfuEPq0q2kX47M2k5/Lk+cAv9dOuJtFIOle3JHV0e2SHIBxvcF43oc29ibG16RLZkmzQbkIww+zPbyAXpV1BKUA60/JzLZicOxpBwKfJa16I6ln9o5804e/kTI7aXmX76vzodsr2WqeJ9d3Z/5kDL8=
-  distributions: sdist bdist_wheel
-notifications:
-  email: false
diff --git a/LICENSE b/LICENSE
index 4276f7d..0a8d754 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
 MIT License
 
-Copyright (c) 2019 Matthew Matl
+Copyright (c) 2019 Matthew Matl, Adam Fishman
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index a3c7161..dcc0b55 100644
--- a/README.md
+++ b/README.md
@@ -1,27 +1,18 @@
-# Urdfpy
+# Urchin
 
-[![Build Status](https://travis-ci.org/mmatl/urdfpy.svg?branch=master)](https://travis-ci.org/mmatl/urdfpy)
-[![Documentation Status](https://readthedocs.org/projects/urdfpy/badge/?version=latest)](https://urdfpy.readthedocs.io/en/latest/?badge=latest)
-[![Coverage Status](https://coveralls.io/repos/github/mmatl/urdfpy/badge.svg?branch=master)](https://coveralls.io/github/mmatl/urdfpy?branch=master)
-[![PyPI version](https://badge.fury.io/py/urdfpy.svg)](https://badge.fury.io/py/urdfpy)
-
-Urdfpy is a simple and easy-to-use library for loading, manipulating, saving,
-and visualizing URDF files.
-
-Extensive API documentation is provided [here](https://urdfpy.readthedocs.io/en/latest/).
-
-<p float="left">
-  <img src="https://github.com/mmatl/urdfpy/blob/master/docs/source/_static/robotiq.gif?raw=true" alt="GIF of Viewer" width="300"/>
-  <img src="https://github.com/mmatl/urdfpy/blob/master/docs/source/_static/ur5.gif?raw=true" alt="GIF of Viewer" width="300"/>
-</p>
+Urchin is a fork of the no-longer-updated `urdfpy`. The API is still mostly the
+same. The main difference currently is that you can load urdfs without the
+meshes (which is ~100x faster) by passing the flag `lazy_load=True` to the `URDF.load` function.
 
 ## Installation
-You can install urdfpy directly from pip.
-
+You can install urchin directly from pip.
 ```bash
-pip install urdfpy
+pip install urchin
 ```
+## Documentation
+
+See the urdfpy documentation for the current docs. `urchin` will have its own
+docs when it diverges enough to deserve it.
 
-## User Guide
-Please see the user guide [here](https://urdfpy.readthedocs.io/en/latest/examples/index.html) for
+The urdfpy docs can be found [here](https://urdfpy.readthedocs.io/en/latest/examples/index.html) for
 more information.
diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst
index 793b71c..ae8651f 100644
--- a/docs/source/api/index.rst
+++ b/docs/source/api/index.rst
@@ -3,7 +3,7 @@
 API Reference
 =============
 
-.. automodapi:: urdfpy
+.. automodapi:: urchin
    :no-inheritance-diagram:
    :no-main-docstr:
    :no-heading:
diff --git a/docs/source/conf.py b/docs/source/conf.py
index d904b40..6fadac3 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -17,19 +17,20 @@
 
 import sphinx_rtd_theme
 from sphinx.domains.python import PythonDomain
-sys.path.insert(0, os.path.abspath('../../'))
+
+sys.path.insert(0, os.path.abspath("../../"))
 
 
 # -- Project information -----------------------------------------------------
 
-project = u'urdfpy'
-copyright = u'2019, Matthew Matl'
-author = u'Matthew Matl'
+project = "urchin"
+copyright = "2022, Matthew Matl, Adam Fishman"
+author = "Matthew Matl, Adam Fishman"
 
 # The short X.Y version
-version = u''
+version = ""
 # The full version, including alpha/beta/rc tags
-release = u''
+release = ""
 
 
 # -- General configuration ---------------------------------------------------
@@ -42,31 +43,31 @@
 # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
 # ones.
 extensions = [
-    'sphinx.ext.autodoc',
-    'sphinx.ext.autosummary',
-    'sphinx.ext.coverage',
-    'sphinx.ext.githubpages',
-    'sphinx.ext.intersphinx',
-    'sphinx.ext.napoleon',
-    'sphinx.ext.viewcode',
-    'sphinx_automodapi.automodapi',
-    'sphinx_automodapi.smart_resolver'
+    "sphinx.ext.autodoc",
+    "sphinx.ext.autosummary",
+    "sphinx.ext.coverage",
+    "sphinx.ext.githubpages",
+    "sphinx.ext.intersphinx",
+    "sphinx.ext.napoleon",
+    "sphinx.ext.viewcode",
+    "sphinx_automodapi.automodapi",
+    "sphinx_automodapi.smart_resolver",
 ]
 numpydoc_class_members_toctree = False
-automodapi_toctreedirnm = 'generated'
+automodapi_toctreedirnm = "generated"
 automodsumm_inherited_members = True
 
 # Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
+templates_path = ["_templates"]
 
 # The suffix(es) of source filenames.
 # You can specify multiple suffix as a list of string:
 #
 # source_suffix = ['.rst', '.md']
-source_suffix = '.rst'
+source_suffix = ".rst"
 
 # The master toctree document.
-master_doc = 'index'
+master_doc = "index"
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
@@ -90,7 +91,7 @@
 # a list of builtin themes.
 #
 # html_theme = 'alabaster'
-html_theme = 'sphinx_rtd_theme'
+html_theme = "sphinx_rtd_theme"
 html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
 
 # Theme options are theme-specific and customize the look and feel of a theme
@@ -102,7 +103,7 @@
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
 # so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+html_static_path = ["_static"]
 
 # Custom sidebar templates, must be a dictionary that maps document names
 # to template names.
@@ -118,7 +119,7 @@
 # -- Options for HTMLHelp output ---------------------------------------------
 
 # Output file base name for HTML help builder.
-htmlhelp_basename = 'urdfpydoc'
+htmlhelp_basename = "urchindoc"
 
 
 # -- Options for LaTeX output ------------------------------------------------
@@ -127,15 +128,12 @@
     # The paper size ('letterpaper' or 'a4paper').
     #
     # 'papersize': 'letterpaper',
-
     # The font size ('10pt', '11pt' or '12pt').
     #
     # 'pointsize': '10pt',
-
     # Additional stuff for the LaTeX preamble.
     #
     # 'preamble': '',
-
     # Latex figure (float) alignment
     #
     # 'figure_align': 'htbp',
@@ -145,8 +143,13 @@
 # (source start file, target name, title,
 #  author, documentclass [howto, manual, or own class]).
 latex_documents = [
-    (master_doc, 'urdfpy.tex', u'urdfpy Documentation',
-     u'Matthew Matl', 'manual'),
+    (
+        master_doc,
+        "urchin.tex",
+        "urchin Documentation",
+        "Matthew Matl, Adam Fishman",
+        "manual",
+    ),
 ]
 
 
@@ -154,10 +157,7 @@
 
 # One entry per manual page. List of tuples
 # (source start file, name, description, authors, manual section).
-man_pages = [
-    (master_doc, 'urdfpy', u'urdfpy Documentation',
-     [author], 1)
-]
+man_pages = [(master_doc, "urchin", "urchin Documentation", [author], 1)]
 
 
 # -- Options for Texinfo output ----------------------------------------------
@@ -166,9 +166,15 @@
 # (source start file, target name, title, author,
 #  dir menu entry, description, category)
 texinfo_documents = [
-    (master_doc, 'urdfpy', u'urdfpy Documentation',
-     author, 'urdfpy', 'One line description of project.',
-     'Miscellaneous'),
+    (
+        master_doc,
+        "urchin",
+        "urchin Documentation",
+        author,
+        "urchin",
+        "One line description of project.",
+        "Miscellaneous",
+    ),
 ]
 
 
@@ -187,7 +193,7 @@
 # epub_uid = ''
 
 # A list of files that should not be packed into the epub file.
-epub_exclude_files = ['search.html']
+epub_exclude_files = ["search.html"]
 
 
 # -- Extension configuration -------------------------------------------------
@@ -196,8 +202,8 @@
 
 # Example configuration for intersphinx: refer to the Python standard library.
 intersphinx_mapping = {
-    'python': ('https://docs.python.org/3/', None),
-    'trimesh': ('https://trimsh.org/', None),
+    "python": ("https://docs.python.org/3/", None),
+    "trimesh": ("https://trimsh.org/", None),
 }
 
 # Autosummary fix
@@ -208,7 +214,6 @@
 
 
 class MyPythonDomain(PythonDomain):
-
     def find_obj(self, env, modname, classname, name, type, searchmode=0):
         """Ensures an object always resolves to the desired module
         if defined there."""
@@ -221,14 +226,14 @@ def find_obj(self, env, modname, classname, name, type, searchmode=0):
 
         # If multiple matches, try to take the shortest if all the modules are
         # the same
-        first_match_name_sp = orig_matches[0][0].split('.')
+        first_match_name_sp = orig_matches[0][0].split(".")
         base_name = first_match_name_sp[0]
         min_len = len(first_match_name_sp)
         best_match = orig_matches[0]
 
         for match in orig_matches[1:]:
             match_name = match[0]
-            match_name_sp = match_name.split('.')
+            match_name_sp = match_name.split(".")
             match_base = match_name_sp[0]
 
             # If we have mismatched bases, return them all to trigger warnings
diff --git a/docs/source/development/index.rst b/docs/source/development/index.rst
index 2c43051..8af28c3 100644
--- a/docs/source/development/index.rst
+++ b/docs/source/development/index.rst
@@ -3,19 +3,19 @@
 Development Guide
 =================
 
-Read this guide before doing development in ``urdfpy``.
+Read this guide before doing development in ``urchin``.
 
 Setting Up
 ----------
 
 To set up the tools you'll need for developing, you'll need to install
-``urdfpy`` in development mode. Start by installing the development
+``urchin`` in development mode. Start by installing the development
 dependencies:
 
 .. code-block:: bash
 
-   git clone https://github.com/mmatl/urdfpy.git
-   cd urdfpy
+   git clone https://github.com/fishbotics/urchin.git
+   cd urchin
    pip install -e .[dev,docs]
 
 Next, install the git pre-commit hooks with the following command:
@@ -62,7 +62,7 @@ Their website has tons of useful details, but here are the basics.
 .. _pytest: https://docs.pytest.org/en/latest/
 
 To run the testing suite, simply navigate to the top-level folder
-in ``urdfpy`` and run the following command:
+in ``urchin`` and run the following command:
 
 .. code-block:: bash
 
@@ -103,7 +103,7 @@ option set:
 
 .. code-block:: bash
 
-   pytest --cov=urdfpy tests
+   pytest --cov=urchin tests
 
 This will dump a code coverage file. To view it in a nice HTML document,
 run the following command:
@@ -117,7 +117,7 @@ The generated HTML homepage will be stored at ``htmlcov/index.html``.
 Building Documentation
 ----------------------
 
-To build ``urdfpy``'s documentation, go to the ``docs`` directory and run
+To build ``urchin``'s documentation, go to the ``docs`` directory and run
 ``make`` with the appropriate target.
 For example,
 
diff --git a/docs/source/examples/index.rst b/docs/source/examples/index.rst
index b19d99a..be9c9da 100644
--- a/docs/source/examples/index.rst
+++ b/docs/source/examples/index.rst
@@ -13,7 +13,7 @@ Loading from a File
 You can load a URDF from any ``.urdf`` file, as long as you fix the links
 to be relative or absolute links rather than ROS resource URLs.
 
->>> from urdfpy import URDF
+>>> from urchin import URDF
 >>> robot = URDF.load('tests/data/ur5/ur5.urdf')
 
 Saving to a File
@@ -120,11 +120,11 @@ trimesh.base.Trimesh
 Visualization
 -------------
 
-Urdfpy also comes bundled with two simple visualization functions.
+Urchin also comes bundled with two simple visualization functions.
 
 You can visualize a robot in a static configuration:
 
->>> robot.show(cfg={ 
+>>> robot.show(cfg={
 ...     'shoulder_lift_joint': -2.0,
 ...     'elbow_joint': 2.0
 ... })
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 90825c9..842f6d3 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -1,8 +1,8 @@
 .. _root:
 
-Urdfpy Documentation
+Urchin Documentation
 ====================
-Urdfpy is a simple pure-Python library for loading, manipulating, and exporting
+Urchin is a simple pure-Python library for loading, manipulating, and exporting
 URDF files and robot specifications. For example, here's a rendering of a UR5
 robot moving around after being loaded by this library.
 
diff --git a/scripts/vis_urdf.py b/scripts/vis_urdf.py
index b1d22c7..bd535ab 100644
--- a/scripts/vis_urdf.py
+++ b/scripts/vis_urdf.py
@@ -4,24 +4,21 @@
 """
 import argparse
 
-import urdfpy
+import urchin
 
-if __name__ == '__main__':
+if __name__ == "__main__":
 
     # Parse Args
-    parser = argparse.ArgumentParser(
-        description='Visualize a robot from a URDF file'
+    parser = argparse.ArgumentParser(description="Visualize a robot from a URDF file")
+    parser.add_argument(
+        "urdf", type=str, help="Path to URDF file that describes the robot"
     )
-    parser.add_argument('urdf', type=str,
-                        help='Path to URDF file that describes the robot')
-    parser.add_argument('-a', action='store_true',
-                        help='Visualize robot articulation')
-    parser.add_argument('-c', action='store_true',
-                        help='Use collision geometry')
+    parser.add_argument("-a", action="store_true", help="Visualize robot articulation")
+    parser.add_argument("-c", action="store_true", help="Use collision geometry")
 
     args = parser.parse_args()
 
-    robot = urdfpy.URDF.load(args.urdf)
+    robot = urchin.URDF.load(args.urdf)
 
     if args.a:
         robot.animate(use_collision=args.c)
diff --git a/setup.py b/setup.py
index cd8f3b5..2b81d2d 100644
--- a/setup.py
+++ b/setup.py
@@ -1,61 +1,59 @@
 """
-Author: Matthew Matl
+Author: Adam Fishman
 """
 from setuptools import setup
 
 requirements = [
-    'lxml',             # For XML DOM Tree
-    'networkx==2.2',    # For joint graph
-    'numpy',            # Numpy
-    'pillow',           # For texture image loading
-    'pycollada==0.6',   # COLLADA (.dae) mesh loading via trimesh
-    'pyrender>=0.1.20', # For visualization
-    'scipy',            # For trimesh, annoyingly
-    'six',              # Python 2/3 compatability
-    'trimesh',          # Mesh geometry loading/creation/saving
+    "lxml",  # For XML DOM Tree
+    "networkx",  # For joint graph
+    "numpy",  # Numpy
+    "pillow",  # For texture image loading
+    "pycollada>=0.6",  # COLLADA (.dae) mesh loading via trimesh
+    "pyribbit>=0.1.46",  # For visualization
+    "scipy",  # For trimesh, annoyingly
+    "six",  # Python 2/3 compatability
+    "trimesh",  # Mesh geometry loading/creation/saving
 ]
 
 dev_requirements = [
-    'flake8',            # Code formatting checker
-    'pre-commit',        # Pre-commit hooks
-    'pytest',            # Code testing
-    'pytest-cov',        # Coverage testing
-    'tox',               # Automatic virtualenv testing
+    "flake8",  # Code formatting checker
+    "pre-commit",  # Pre-commit hooks
+    "pytest",  # Code testing
+    "pytest-cov",  # Coverage testing
+    "tox",  # Automatic virtualenv testing
 ]
 
 docs_requirements = [
-    'sphinx',            # General doc library
-    'sphinx_rtd_theme',  # RTD theme for sphinx
-    'sphinx-automodapi'  # For generating nice tables
+    "sphinx",  # General doc library
+    "sphinx_rtd_theme",  # RTD theme for sphinx
+    "sphinx-automodapi",  # For generating nice tables
 ]
 
-exec(open('urdfpy/version.py').read())
-
 setup(
-    name='urdfpy',
-    version=__version__,
-    description='URDF parser and manipulator for Python',
-    long_description='URDF parser and manipulator for Python',
-    author='Matthew Matl',
-    author_email='matthewcmatl@gmail.com',
-    license='MIT License',
-    url='https://github.com/mmatl/urdfpy',
-    keywords='robotics ros urdf robots parser',
+    name="urchin",
+    version="0.0.28",
+    description="URDF parser and manipulator for Python",
+    long_description="URDF parser and manipulator for Python. This package is a fork of urdfpy, which seems to be no longer maintained. ",
+    author="Adam Fishman",
+    author_email="hello@fishbotics.com",
+    license="MIT License",
+    url="https://github.com/fishbotics/urchin",
+    keywords="robotics ros urdf robots parser",
     classifiers=[
-        'Development Status :: 4 - Beta',
-        'License :: OSI Approved :: MIT License',
-        'Programming Language :: Python',
-        'Programming Language :: Python :: 2.7',
-        'Programming Language :: Python :: 3.5',
-        'Programming Language :: Python :: 3.6',
-        'Natural Language :: English',
-        'Topic :: Scientific/Engineering'
+        "Development Status :: 4 - Beta",
+        "License :: OSI Approved :: MIT License",
+        "Programming Language :: Python",
+        "Programming Language :: Python :: 2.7",
+        "Programming Language :: Python :: 3.5",
+        "Programming Language :: Python :: 3.6",
+        "Natural Language :: English",
+        "Topic :: Scientific/Engineering",
     ],
-    packages=['urdfpy'],
-    setup_requires = requirements,
+    packages=["urchin"],
+    setup_requires=requirements,
     install_requires=requirements,
     extras_require={
-        'dev': dev_requirements,
-        'docs': docs_requirements,
-    }
+        "dev": dev_requirements,
+        "docs": docs_requirements,
+    },
 )
diff --git a/urdfpy/__init__.py b/urchin/__init__.py
similarity index 86%
rename from urdfpy/__init__.py
rename to urchin/__init__.py
index 5292d2e..460e48e 100644
--- a/urdfpy/__init__.py
+++ b/urchin/__init__.py
@@ -1,4 +1,4 @@
-from .urdf import (URDFType,
+from .urdf import (URDFType, URDFTypeWithMesh,
                    Box, Cylinder, Sphere, Mesh, Geometry,
                    Texture, Material,
                    Collision, Visual, Inertial,
@@ -10,7 +10,7 @@
 from .version import __version__
 
 __all__ = [
-    'URDFType', 'Box', 'Cylinder', 'Sphere', 'Mesh', 'Geometry',
+    'URDFType', 'URDFTypeWithMesh', 'Box', 'Cylinder', 'Sphere', 'Mesh', 'Geometry',
     'Texture', 'Material', 'Collision', 'Visual', 'Inertial',
     'JointCalibration', 'JointDynamics', 'JointLimit', 'JointMimic',
     'SafetyController', 'Actuator', 'TransmissionJoint',
diff --git a/urdfpy/urdf.py b/urchin/urdf.py
similarity index 79%
rename from urdfpy/urdf.py
rename to urchin/urdf.py
index 14b0216..f509549 100644
--- a/urdfpy/urdf.py
+++ b/urchin/urdf.py
@@ -10,8 +10,13 @@
 import trimesh
 import six
 
-from .utils import (parse_origin, unparse_origin, get_filename, load_meshes,
-                    configure_origin)
+from .utils import (
+    parse_origin,
+    unparse_origin,
+    get_filename,
+    load_meshes,
+    configure_origin,
+)
 
 
 class URDFType(object):
@@ -36,9 +41,10 @@ class URDFType(object):
     - ``_TAG`` - This is a string that represents the XML tag for the node
       containing this type of object.
     """
-    _ATTRIBS = {}   # Map from attrib name to (type, required)
+
+    _ATTRIBS = {}  # Map from attrib name to (type, required)
     _ELEMENTS = {}  # Map from element name to (type, required, multiple)
-    _TAG = ''       # XML tag for this element
+    _TAG = ""  # XML tag for this element
 
     def __init__(self):
         pass
@@ -60,7 +66,7 @@ def _parse_attrib(cls, val_type, val):
             The parsed attribute.
         """
         if val_type == np.ndarray:
-            val = np.fromstring(val, sep=' ')
+            val = np.fromstring(val, sep=" ")
         else:
             val = val_type(val)
         return val
@@ -89,8 +95,8 @@ def _parse_simple_attribs(cls, node):
                     v = cls._parse_attrib(t, node.attrib[a])
                 except Exception:
                     raise ValueError(
-                        'Missing required attribute {} when parsing an object '
-                        'of type {}'.format(a, cls.__name__)
+                        "Missing required attribute {} when parsing an object "
+                        "of type {}".format(a, cls.__name__)
                     )
             else:
                 v = None
@@ -111,6 +117,9 @@ def _parse_simple_elements(cls, node, path):
         path : str
             The string path where the XML file is located (used for resolving
             the location of mesh or image files).
+        lazy_load_meshes : bool
+            Whether a mesh element should be immediately loaded or loaded when
+            needed
 
         Returns
         -------
@@ -129,10 +138,8 @@ def _parse_simple_elements(cls, node, path):
                 vs = node.findall(t._TAG)
                 if len(vs) == 0 and r:
                     raise ValueError(
-                        'Missing required subelement(s) of type {} when '
-                        'parsing an object of type {}'.format(
-                            t.__name__, cls.__name__
-                        )
+                        "Missing required subelement(s) of type {} when "
+                        "parsing an object of type {}".format(t.__name__, cls.__name__)
                     )
                 v = [t._from_xml(n, path) for n in vs]
             kwargs[a] = v
@@ -282,6 +289,100 @@ def _to_xml(self, parent, path):
         return self._unparse(path)
 
 
+class URDFTypeWithMesh(URDFType):
+    @classmethod
+    def _parse_simple_elements(cls, node, path, lazy_load_meshes):
+        """Parse all elements in the _ELEMENTS array from the children of
+        this node.
+
+        Parameters
+        ----------
+        node : :class:`lxml.etree.Element`
+            The node to parse children for.
+        path : str
+            The string path where the XML file is located (used for resolving
+            the location of mesh or image files).
+        lazy_load_meshes : bool
+            Whether a mesh element should be immediately loaded or loaded when
+            needed
+
+        Returns
+        -------
+        kwargs : dict
+            Map from element names to the :class:`URDFType` subclass (or list,
+            if ``multiple`` was set) created for that element.
+        """
+        kwargs = {}
+        for a in cls._ELEMENTS:
+            t, r, m = cls._ELEMENTS[a]
+            if not m:
+                v = node.find(t._TAG)
+                if r or v is not None:
+                    if issubclass(t, URDFTypeWithMesh):
+                        v = t._from_xml(v, path, lazy_load_meshes)
+                    else:
+                        v = t._from_xml(v, path)
+            else:
+                vs = node.findall(t._TAG)
+                if len(vs) == 0 and r:
+                    raise ValueError(
+                        "Missing required subelement(s) of type {} when "
+                        "parsing an object of type {}".format(t.__name__, cls.__name__)
+                    )
+                if issubclass(t, URDFTypeWithMesh):
+                    v = [t._from_xml(n, path, lazy_load_meshes) for n in vs]
+                else:
+                    v = [t._from_xml(n, path) for n in vs]
+            kwargs[a] = v
+        return kwargs
+
+    @classmethod
+    def _parse(cls, node, path, lazy_load_meshes):
+        """Parse all elements and attributes in the _ELEMENTS and _ATTRIBS
+        arrays for a node.
+
+        Parameters
+        ----------
+        node : :class:`lxml.etree.Element`
+            The node to parse.
+        path : str
+            The string path where the XML file is located (used for resolving
+            the location of mesh or image files).
+        lazy_load_meshes : bool
+            Whether meshes should be loaded immediately or upon their first use
+
+        Returns
+        -------
+        kwargs : dict
+            Map from names to Python classes created from the attributes
+            and elements in the class arrays.
+        """
+        kwargs = cls._parse_simple_attribs(node)
+        kwargs.update(cls._parse_simple_elements(node, path, lazy_load_meshes))
+        return kwargs
+
+    @classmethod
+    def _from_xml(cls, node, path, lazy_load_meshes):
+        """Create an instance of this class from an XML node.
+
+        Parameters
+        ----------
+        node : :class:`lxml.etree.Element`
+            The node to parse.
+        path : str
+            The string path where the XML file is located (used for resolving
+            the location of mesh or image files).
+        lazy_load_meshes : bool
+            Whether meshes should be loaded immediately or upon their first use
+
+        Returns
+        -------
+        obj : :class:`URDFType`
+            An instance of this class parsed from the node.
+        """
+        return cls(**cls._parse(node, path, lazy_load_meshes))
+
+
 ###############################################################################
 # Link types
 ###############################################################################
@@ -296,10 +397,8 @@ class Box(URDFType):
         The length, width, and height of the box in meters.
     """
 
-    _ATTRIBS = {
-        'size': (np.ndarray, True)
-    }
-    _TAG = 'box'
+    _ATTRIBS = {"size": (np.ndarray, True)}
+    _TAG = "box"
 
     def __init__(self, size):
         self.size = size
@@ -307,8 +406,7 @@ def __init__(self, size):
 
     @property
     def size(self):
-        """(3,) float : The length, width, and height of the box in meters.
-        """
+        """(3,) float : The length, width, and height of the box in meters."""
         return self._size
 
     @size.setter
@@ -325,7 +423,7 @@ def meshes(self):
             self._meshes = [trimesh.creation.box(extents=self.size)]
         return self._meshes
 
-    def copy(self, prefix='', scale=None):
+    def copy(self, prefix="", scale=None):
         """Create a deep copy with the prefix applied to all names.
 
         Parameters
@@ -358,10 +456,10 @@ class Cylinder(URDFType):
     """
 
     _ATTRIBS = {
-        'radius': (float, True),
-        'length': (float, True),
+        "radius": (float, True),
+        "length": (float, True),
     }
-    _TAG = 'cylinder'
+    _TAG = "cylinder"
 
     def __init__(self, radius, length):
         self.radius = radius
@@ -370,8 +468,7 @@ def __init__(self, radius, length):
 
     @property
     def radius(self):
-        """float : The radius of the cylinder in meters.
-        """
+        """float : The radius of the cylinder in meters."""
         return self._radius
 
     @radius.setter
@@ -381,8 +478,7 @@ def radius(self, value):
 
     @property
     def length(self):
-        """float : The length of the cylinder in meters.
-        """
+        """float : The length of the cylinder in meters."""
         return self._length
 
     @length.setter
@@ -396,12 +492,12 @@ def meshes(self):
         that represent this object.
         """
         if len(self._meshes) == 0:
-            self._meshes = [trimesh.creation.cylinder(
-                radius=self.radius, height=self.length
-            )]
+            self._meshes = [
+                trimesh.creation.cylinder(radius=self.radius, height=self.length)
+            ]
         return self._meshes
 
-    def copy(self, prefix='', scale=None):
+    def copy(self, prefix="", scale=None):
         """Create a deep copy with the prefix applied to all names.
 
         Parameters
@@ -418,7 +514,9 @@ def copy(self, prefix='', scale=None):
             scale = 1.0
         if isinstance(scale, (list, np.ndarray)):
             if scale[0] != scale[1]:
-                raise ValueError('Cannot rescale cylinder geometry with asymmetry in x/y')
+                raise ValueError(
+                    "Cannot rescale cylinder geometry with asymmetry in x/y"
+                )
             c = Cylinder(
                 radius=self.radius * scale[0],
                 length=self.length * scale[2],
@@ -439,10 +537,11 @@ class Sphere(URDFType):
     radius : float
         The radius of the sphere in meters.
     """
+
     _ATTRIBS = {
-        'radius': (float, True),
+        "radius": (float, True),
     }
-    _TAG = 'sphere'
+    _TAG = "sphere"
 
     def __init__(self, radius):
         self.radius = radius
@@ -450,8 +549,7 @@ def __init__(self, radius):
 
     @property
     def radius(self):
-        """float : The radius of the sphere in meters.
-        """
+        """float : The radius of the sphere in meters."""
         return self._radius
 
     @radius.setter
@@ -468,7 +566,7 @@ def meshes(self):
             self._meshes = [trimesh.creation.icosphere(radius=self.radius)]
         return self._meshes
 
-    def copy(self, prefix='', scale=None):
+    def copy(self, prefix="", scale=None):
         """Create a deep copy with the prefix applied to all names.
 
         Parameters
@@ -485,7 +583,7 @@ def copy(self, prefix='', scale=None):
             scale = 1.0
         if isinstance(scale, (list, np.ndarray)):
             if scale[0] != scale[1] or scale[0] != scale[2]:
-                raise ValueError('Spheres do not support non-uniform scaling!')
+                raise ValueError("Spheres do not support non-uniform scaling!")
             scale = scale[0]
         s = Sphere(
             radius=self.radius * scale,
@@ -493,7 +591,7 @@ def copy(self, prefix='', scale=None):
         return s
 
 
-class Mesh(URDFType):
+class Mesh(URDFTypeWithMesh):
     """A triangular mesh object.
 
     Parameters
@@ -510,23 +608,25 @@ class Mesh(URDFType):
         might be composed of separate trimesh objects.
         If not specified, the mesh is loaded from the file using trimesh.
     """
-    _ATTRIBS = {
-        'filename': (str, True),
-        'scale': (np.ndarray, False)
-    }
-    _TAG = 'mesh'
 
-    def __init__(self, filename, scale=None, meshes=None):
+    _ATTRIBS = {"filename": (str, True), "scale": (np.ndarray, False)}
+    _TAG = "mesh"
+
+    def __init__(self, filename, combine, scale=None, meshes=None, lazy_filename=None):
         if meshes is None:
-            meshes = load_meshes(filename)
+            if lazy_filename is None:
+                meshes = load_meshes(filename)
+            else:
+                meshes = None
         self.filename = filename
         self.scale = scale
+        self.lazy_filename = lazy_filename
+        self.combine = combine
         self.meshes = meshes
 
     @property
     def filename(self):
-        """str : The path to the mesh file for this object.
-        """
+        """str : The path to the mesh file for this object."""
         return self._filename
 
     @filename.setter
@@ -535,8 +635,7 @@ def filename(self, value):
 
     @property
     def scale(self):
-        """(3,) float : A scaling for the mesh along its local XYZ axes.
-        """
+        """(3,) float : A scaling for the mesh along its local XYZ axes."""
         return self._scale
 
     @scale.setter
@@ -550,41 +649,59 @@ def meshes(self):
         """list of :class:`~trimesh.base.Trimesh` : The triangular meshes
         that represent this object.
         """
+        if self.lazy_filename is not None and self._meshes is None:
+            self.meshes = self._load_and_combine_meshes(
+                self.lazy_filename, self.combine
+            )
         return self._meshes
 
     @meshes.setter
     def meshes(self, value):
-        if isinstance(value, six.string_types):
+        if self.lazy_filename is not None and value is None:
+            self._meshes = None
+        elif isinstance(value, six.string_types):
             value = load_meshes(value)
         elif isinstance(value, (list, tuple, set, np.ndarray)):
             value = list(value)
             if len(value) == 0:
-                raise ValueError('Mesh must have at least one trimesh.Trimesh')
+                raise ValueError("Mesh must have at least one trimesh.Trimesh")
             for m in value:
                 if not isinstance(m, trimesh.Trimesh):
-                    raise TypeError('Mesh requires a trimesh.Trimesh or a '
-                                    'list of them')
+                    raise TypeError(
+                        "Mesh requires a trimesh.Trimesh or a " "list of them"
+                    )
         elif isinstance(value, trimesh.Trimesh):
             value = [value]
         else:
-            raise TypeError('Mesh requires a trimesh.Trimesh')
+            raise TypeError("Mesh requires a trimesh.Trimesh")
         self._meshes = value
 
-    @classmethod
-    def _from_xml(cls, node, path):
-        kwargs = cls._parse(node, path)
-
-        # Load the mesh, combining collision geometry meshes but keeping
-        # visual ones separate to preserve colors and textures
-        fn = get_filename(path, kwargs['filename'])
-        combine = node.getparent().getparent().tag == Collision._TAG
+    @staticmethod
+    def _load_and_combine_meshes(fn, combine):
         meshes = load_meshes(fn)
         if combine:
             # Delete visuals for simplicity
             for m in meshes:
                 m.visual = trimesh.visual.ColorVisuals(mesh=m)
             meshes = [meshes[0] + meshes[1:]]
-        kwargs['meshes'] = meshes
+        return meshes
+
+    @classmethod
+    def _from_xml(cls, node, path, lazy_load_meshes):
+        kwargs = cls._parse(node, path, lazy_load_meshes)
+
+        # Load the mesh, combining collision geometry meshes but keeping
+        # visual ones separate to preserve colors and textures
+        fn = get_filename(path, kwargs["filename"])
+        combine = node.getparent().getparent().tag == Collision._TAG
+        if not lazy_load_meshes:
+            meshes = cls._load_and_combine_meshes(fn, combine)
+            kwargs["lazy_filename"] = None
+        else:
+            meshes = None
+            kwargs["lazy_filename"] = fn
+        kwargs["meshes"] = meshes
+        kwargs["combine"] = combine
 
         return Mesh(**kwargs)
 
@@ -593,18 +710,19 @@ def _to_xml(self, parent, path):
         fn = get_filename(path, self.filename, makedirs=True)
 
         # Export the meshes as a single file
-        meshes = self.meshes
-        if len(meshes) == 1:
-            meshes = meshes[0]
-        elif os.path.splitext(fn)[1] == '.glb':
-            meshes = trimesh.scene.Scene(geometry=meshes)
-        trimesh.exchange.export.export_mesh(meshes, fn)
+        if self._meshes is not None:
+            meshes = self.meshes
+            if len(meshes) == 1:
+                meshes = meshes[0]
+            elif os.path.splitext(fn)[1] == ".glb":
+                meshes = trimesh.scene.Scene(geometry=meshes)
+            trimesh.exchange.export.export_mesh(meshes, fn)
 
         # Unparse the node
         node = self._unparse(path)
         return node
 
-    def copy(self, prefix='', scale=None):
+    def copy(self, prefix="", scale=None):
         """Create a deep copy with the prefix applied to all names.
 
         Parameters
@@ -621,22 +739,24 @@ def copy(self, prefix='', scale=None):
         if scale is not None:
             sm = np.eye(4)
             if isinstance(scale, (list, np.ndarray)):
-                sm[:3,:3] = np.diag(scale)
+                sm[:3, :3] = np.diag(scale)
             else:
-                sm[:3,:3] = np.diag(np.repeat(scale, 3))
+                sm[:3, :3] = np.diag(np.repeat(scale, 3))
             for i, m in enumerate(meshes):
                 meshes[i] = m.apply_transform(sm)
         base, fn = os.path.split(self.filename)
-        fn = '{}{}'.format(prefix, self.filename)
+        fn = "{}{}".format(prefix, self.filename)
         m = Mesh(
             filename=os.path.join(base, fn),
+            combine=self.combine,
             scale=(self.scale.copy() if self.scale is not None else None),
-            meshes=meshes
+            meshes=meshes,
+            lazy_filename=self.lazy_filename,
         )
         return m
 
 
-class Geometry(URDFType):
+class Geometry(URDFTypeWithMesh):
     """A wrapper for all geometry types.
 
     Only one of the following values can be set, all others should be set
@@ -655,17 +775,16 @@ class Geometry(URDFType):
     """
 
     _ELEMENTS = {
-        'box': (Box, False, False),
-        'cylinder': (Cylinder, False, False),
-        'sphere': (Sphere, False, False),
-        'mesh': (Mesh, False, False),
+        "box": (Box, False, False),
+        "cylinder": (Cylinder, False, False),
+        "sphere": (Sphere, False, False),
+        "mesh": (Mesh, False, False),
     }
-    _TAG = 'geometry'
+    _TAG = "geometry"
 
     def __init__(self, box=None, cylinder=None, sphere=None, mesh=None):
-        if (box is None and cylinder is None and
-                sphere is None and mesh is None):
-            raise ValueError('At least one geometry element must be set')
+        if box is None and cylinder is None and sphere is None and mesh is None:
+            raise ValueError("At least one geometry element must be set")
         self.box = box
         self.cylinder = cylinder
         self.sphere = sphere
@@ -673,50 +792,46 @@ def __init__(self, box=None, cylinder=None, sphere=None, mesh=None):
 
     @property
     def box(self):
-        """:class:`.Box` : Box geometry.
-        """
+        """:class:`.Box` : Box geometry."""
         return self._box
 
     @box.setter
     def box(self, value):
         if value is not None and not isinstance(value, Box):
-            raise TypeError('Expected Box type')
+            raise TypeError("Expected Box type")
         self._box = value
 
     @property
     def cylinder(self):
-        """:class:`.Cylinder` : Cylinder geometry.
-        """
+        """:class:`.Cylinder` : Cylinder geometry."""
         return self._cylinder
 
     @cylinder.setter
     def cylinder(self, value):
         if value is not None and not isinstance(value, Cylinder):
-            raise TypeError('Expected Cylinder type')
+            raise TypeError("Expected Cylinder type")
         self._cylinder = value
 
     @property
     def sphere(self):
-        """:class:`.Sphere` : Spherical geometry.
-        """
+        """:class:`.Sphere` : Spherical geometry."""
         return self._sphere
 
     @sphere.setter
     def sphere(self, value):
         if value is not None and not isinstance(value, Sphere):
-            raise TypeError('Expected Sphere type')
+            raise TypeError("Expected Sphere type")
         self._sphere = value
 
     @property
     def mesh(self):
-        """:class:`.Mesh` : Mesh geometry.
-        """
+        """:class:`.Mesh` : Mesh geometry."""
         return self._mesh
 
     @mesh.setter
     def mesh(self, value):
         if value is not None and not isinstance(value, Mesh):
-            raise TypeError('Expected Mesh type')
+            raise TypeError("Expected Mesh type")
         self._mesh = value
 
     @property
@@ -741,7 +856,7 @@ def meshes(self):
         """
         return self.geometry.meshes
 
-    def copy(self, prefix='', scale=None):
+    def copy(self, prefix="", scale=None):
         """Create a deep copy with the prefix applied to all names.
 
         Parameters
@@ -756,8 +871,14 @@ def copy(self, prefix='', scale=None):
         """
         v = Geometry(
             box=(self.box.copy(prefix=prefix, scale=scale) if self.box else None),
-            cylinder=(self.cylinder.copy(prefix=prefix, scale=scale) if self.cylinder else None),
-            sphere=(self.sphere.copy(prefix=prefix, scale=scale) if self.sphere else None),
+            cylinder=(
+                self.cylinder.copy(prefix=prefix, scale=scale)
+                if self.cylinder
+                else None
+            ),
+            sphere=(
+                self.sphere.copy(prefix=prefix, scale=scale) if self.sphere else None
+            ),
             mesh=(self.mesh.copy(prefix=prefix, scale=scale) if self.mesh else None),
         )
         return v
@@ -776,10 +897,8 @@ class Texture(URDFType):
         If not specified, it is loaded automatically from the filename.
     """
 
-    _ATTRIBS = {
-        'filename': (str, True)
-    }
-    _TAG = 'texture'
+    _ATTRIBS = {"filename": (str, True)}
+    _TAG = "texture"
 
     def __init__(self, filename, image=None):
         if image is None:
@@ -789,8 +908,7 @@ def __init__(self, filename, image=None):
 
     @property
     def filename(self):
-        """str : Path to the image for this texture.
-        """
+        """str : Path to the image for this texture."""
         return self._filename
 
     @filename.setter
@@ -799,8 +917,7 @@ def filename(self, value):
 
     @property
     def image(self):
-        """:class:`PIL.Image.Image` : The image for this texture.
-        """
+        """:class:`PIL.Image.Image` : The image for this texture."""
         return self._image
 
     @image.setter
@@ -810,8 +927,7 @@ def image(self, value):
         if isinstance(value, np.ndarray):
             value = PIL.Image.fromarray(value)
         elif not isinstance(value, PIL.Image.Image):
-            raise ValueError('Texture only supports numpy arrays '
-                             'or PIL images')
+            raise ValueError("Texture only supports numpy arrays " "or PIL images")
         self._image = value
 
     @classmethod
@@ -819,8 +935,8 @@ def _from_xml(cls, node, path):
         kwargs = cls._parse(node, path)
 
         # Load image
-        fn = get_filename(path, kwargs['filename'])
-        kwargs['image'] = PIL.Image.open(fn)
+        fn = get_filename(path, kwargs["filename"])
+        kwargs["image"] = PIL.Image.open(fn)
 
         return Texture(**kwargs)
 
@@ -831,7 +947,7 @@ def _to_xml(self, parent, path):
 
         return self._unparse(path)
 
-    def copy(self, prefix='', scale=None):
+    def copy(self, prefix="", scale=None):
         """Create a deep copy with the prefix applied to all names.
 
         Parameters
@@ -844,10 +960,7 @@ def copy(self, prefix='', scale=None):
         :class:`.Texture`
             A deep copy.
         """
-        v = Texture(
-            filename=self.filename,
-            image=self.image.copy()
-        )
+        v = Texture(filename=self.filename, image=self.image.copy())
         return v
 
 
@@ -863,13 +976,12 @@ class Material(URDFType):
     texture : :class:`.Texture`, optional
         A texture for the material.
     """
-    _ATTRIBS = {
-        'name': (str, True)
-    }
+
+    _ATTRIBS = {"name": (str, True)}
     _ELEMENTS = {
-        'texture': (Texture, False, False),
+        "texture": (Texture, False, False),
     }
-    _TAG = 'material'
+    _TAG = "material"
 
     def __init__(self, name, color=None, texture=None):
         self.name = name
@@ -878,8 +990,7 @@ def __init__(self, name, color=None, texture=None):
 
     @property
     def name(self):
-        """str : The name of the material.
-        """
+        """str : The name of the material."""
         return self._name
 
     @name.setter
@@ -888,23 +999,21 @@ def name(self, value):
 
     @property
     def color(self):
-        """(4,) float : The RGBA color of the material, in the range [0,1].
-        """
+        """(4,) float : The RGBA color of the material, in the range [0,1]."""
         return self._color
 
     @color.setter
     def color(self, value):
         if value is not None:
-            value = np.asanyarray(value).astype(np.float)
+            value = np.asanyarray(value).astype(float)
             value = np.clip(value, 0.0, 1.0)
             if value.shape != (4,):
-                raise ValueError('Color must be a (4,) float')
+                raise ValueError("Color must be a (4,) float")
         self._color = value
 
     @property
     def texture(self):
-        """:class:`.Texture` : The texture for the material.
-        """
+        """:class:`.Texture` : The texture for the material."""
         return self._texture
 
     @texture.setter
@@ -914,8 +1023,9 @@ def texture(self, value):
                 image = PIL.Image.open(value)
                 value = Texture(filename=value, image=image)
             elif not isinstance(value, Texture):
-                raise ValueError('Invalid type for texture -- expect path to '
-                                 'image or Texture')
+                raise ValueError(
+                    "Invalid type for texture -- expect path to " "image or Texture"
+                )
         self._texture = value
 
     @classmethod
@@ -923,10 +1033,10 @@ def _from_xml(cls, node, path):
         kwargs = cls._parse(node, path)
 
         # Extract the color -- it's weirdly an attribute of a subelement
-        color = node.find('color')
+        color = node.find("color")
         if color is not None:
-            color = np.fromstring(color.attrib['rgba'], sep=' ', dtype=np.float64)
-        kwargs['color'] = color
+            color = np.fromstring(color.attrib["rgba"], sep=" ", dtype=np.float64)
+        kwargs["color"] = color
 
         return Material(**kwargs)
 
@@ -934,20 +1044,20 @@ def _to_xml(self, parent, path):
         # Simplify materials by collecting them at the top level.
 
         # For top-level elements, save the full material specification
-        if parent.tag == 'robot':
+        if parent.tag == "robot":
             node = self._unparse(path)
             if self.color is not None:
-                color = ET.Element('color')
-                color.attrib['rgba'] = np.array2string(self.color)[1:-1]
+                color = ET.Element("color")
+                color.attrib["rgba"] = np.array2string(self.color)[1:-1]
                 node.append(color)
 
         # For non-top-level elements just save the material with a name
         else:
-            node = ET.Element('material')
-            node.attrib['name'] = self.name
+            node = ET.Element("material")
+            node.attrib["name"] = self.name
         return node
 
-    def copy(self, prefix='', scale=None):
+    def copy(self, prefix="", scale=None):
         """Create a deep copy of the material with the prefix applied to all names.
 
         Parameters
@@ -961,13 +1071,13 @@ def copy(self, prefix='', scale=None):
             A deep copy of the material.
         """
         return Material(
-            name='{}{}'.format(prefix, self.name),
+            name="{}{}".format(prefix, self.name),
             color=self.color,
-            texture=self.texture
+            texture=self.texture,
         )
 
 
-class Collision(URDFType):
+class Collision(URDFTypeWithMesh):
     """Collision properties of a link.
 
     Parameters
@@ -981,13 +1091,11 @@ class Collision(URDFType):
         Defaults to identity.
     """
 
-    _ATTRIBS = {
-        'name': (str, False)
-    }
+    _ATTRIBS = {"name": (str, False)}
     _ELEMENTS = {
-        'geometry': (Geometry, True, False),
+        "geometry": (Geometry, True, False),
     }
-    _TAG = 'collision'
+    _TAG = "collision"
 
     def __init__(self, name, origin, geometry):
         self.geometry = geometry
@@ -996,20 +1104,18 @@ def __init__(self, name, origin, geometry):
 
     @property
     def geometry(self):
-        """:class:`.Geometry` : The geometry of this element.
-        """
+        """:class:`.Geometry` : The geometry of this element."""
         return self._geometry
 
     @geometry.setter
     def geometry(self, value):
         if not isinstance(value, Geometry):
-            raise TypeError('Must set geometry with Geometry object')
+            raise TypeError("Must set geometry with Geometry object")
         self._geometry = value
 
     @property
     def name(self):
-        """str : The name of this collision element.
-        """
+        """str : The name of this collision element."""
         return self._name
 
     @name.setter
@@ -1020,8 +1126,7 @@ def name(self, value):
 
     @property
     def origin(self):
-        """(4,4) float : The pose of this element relative to the link frame.
-        """
+        """(4,4) float : The pose of this element relative to the link frame."""
         return self._origin
 
     @origin.setter
@@ -1029,9 +1134,9 @@ def origin(self, value):
         self._origin = configure_origin(value)
 
     @classmethod
-    def _from_xml(cls, node, path):
-        kwargs = cls._parse(node, path)
-        kwargs['origin'] = parse_origin(node)
+    def _from_xml(cls, node, path, lazy_load_meshes):
+        kwargs = cls._parse(node, path, lazy_load_meshes)
+        kwargs["origin"] = parse_origin(node)
         return Collision(**kwargs)
 
     def _to_xml(self, parent, path):
@@ -1039,7 +1144,7 @@ def _to_xml(self, parent, path):
         node.append(unparse_origin(self.origin))
         return node
 
-    def copy(self, prefix='', scale=None):
+    def copy(self, prefix="", scale=None):
         """Create a deep copy of the visual with the prefix applied to all names.
 
         Parameters
@@ -1052,19 +1157,19 @@ def copy(self, prefix='', scale=None):
         :class:`.Visual`
             A deep copy of the visual.
         """
-        origin=self.origin.copy()
+        origin = self.origin.copy()
         if scale is not None:
             if not isinstance(scale, (list, np.ndarray)):
                 scale = np.repeat(scale, 3)
-            origin[:3,3] *= scale
+            origin[:3, 3] *= scale
         return Collision(
-            name='{}{}'.format(prefix, self.name),
+            name="{}{}".format(prefix, self.name),
             origin=origin,
             geometry=self.geometry.copy(prefix=prefix, scale=scale),
         )
 
 
-class Visual(URDFType):
+class Visual(URDFTypeWithMesh):
     """Visual properties of a link.
 
     Parameters
@@ -1079,14 +1184,13 @@ class Visual(URDFType):
     material : :class:`.Material`, optional
         The material of the element.
     """
-    _ATTRIBS = {
-        'name': (str, False)
-    }
+
+    _ATTRIBS = {"name": (str, False)}
     _ELEMENTS = {
-        'geometry': (Geometry, True, False),
-        'material': (Material, False, False),
+        "geometry": (Geometry, True, False),
+        "material": (Material, False, False),
     }
-    _TAG = 'visual'
+    _TAG = "visual"
 
     def __init__(self, geometry, name=None, origin=None, material=None):
         self.geometry = geometry
@@ -1096,20 +1200,18 @@ def __init__(self, geometry, name=None, origin=None, material=None):
 
     @property
     def geometry(self):
-        """:class:`.Geometry` : The geometry of this element.
-        """
+        """:class:`.Geometry` : The geometry of this element."""
         return self._geometry
 
     @geometry.setter
     def geometry(self, value):
         if not isinstance(value, Geometry):
-            raise TypeError('Must set geometry with Geometry object')
+            raise TypeError("Must set geometry with Geometry object")
         self._geometry = value
 
     @property
     def name(self):
-        """str : The name of this visual element.
-        """
+        """str : The name of this visual element."""
         return self._name
 
     @name.setter
@@ -1120,8 +1222,7 @@ def name(self, value):
 
     @property
     def origin(self):
-        """(4,4) float : The pose of this element relative to the link frame.
-        """
+        """(4,4) float : The pose of this element relative to the link frame."""
         return self._origin
 
     @origin.setter
@@ -1130,21 +1231,20 @@ def origin(self, value):
 
     @property
     def material(self):
-        """:class:`.Material` : The material for this element.
-        """
+        """:class:`.Material` : The material for this element."""
         return self._material
 
     @material.setter
     def material(self, value):
         if value is not None:
             if not isinstance(value, Material):
-                raise TypeError('Must set material with Material object')
+                raise TypeError("Must set material with Material object")
         self._material = value
 
     @classmethod
-    def _from_xml(cls, node, path):
-        kwargs = cls._parse(node, path)
-        kwargs['origin'] = parse_origin(node)
+    def _from_xml(cls, node, path, lazy_load_meshes):
+        kwargs = cls._parse(node, path, lazy_load_meshes)
+        kwargs["origin"] = parse_origin(node)
         return Visual(**kwargs)
 
     def _to_xml(self, parent, path):
@@ -1152,7 +1252,7 @@ def _to_xml(self, parent, path):
         node.append(unparse_origin(self.origin))
         return node
 
-    def copy(self, prefix='', scale=None):
+    def copy(self, prefix="", scale=None):
         """Create a deep copy of the visual with the prefix applied to all names.
 
         Parameters
@@ -1165,14 +1265,14 @@ def copy(self, prefix='', scale=None):
         :class:`.Visual`
             A deep copy of the visual.
         """
-        origin=self.origin.copy()
+        origin = self.origin.copy()
         if scale is not None:
             if not isinstance(scale, (list, np.ndarray)):
                 scale = np.repeat(scale, 3)
-            origin[:3,3] *= scale
+            origin[:3, 3] *= scale
         return Visual(
             geometry=self.geometry.copy(prefix=prefix, scale=scale),
-            name='{}{}'.format(prefix, self.name),
+            name="{}{}".format(prefix, self.name),
             origin=origin,
             material=(self.material.copy(prefix=prefix) if self.material else None),
         )
@@ -1191,7 +1291,8 @@ class Inertial(URDFType):
         The pose of the inertials relative to the link frame.
         Defaults to identity if not specified.
     """
-    _TAG = 'inertial'
+
+    _TAG = "inertial"
 
     def __init__(self, mass, inertia, origin=None):
         self.mass = mass
@@ -1200,8 +1301,7 @@ def __init__(self, mass, inertia, origin=None):
 
     @property
     def mass(self):
-        """float : The mass of the link in kilograms.
-        """
+        """float : The mass of the link in kilograms."""
         return self._mass
 
     @mass.setter
@@ -1210,21 +1310,19 @@ def mass(self, value):
 
     @property
     def inertia(self):
-        """(3,3) float : The 3x3 symmetric rotational inertia matrix.
-        """
+        """(3,3) float : The 3x3 symmetric rotational inertia matrix."""
         return self._inertia
 
     @inertia.setter
     def inertia(self, value):
         value = np.asanyarray(value).astype(np.float64)
         if not np.allclose(value, value.T):
-            raise ValueError('Inertia must be a symmetric matrix')
+            raise ValueError("Inertia must be a symmetric matrix")
         self._inertia = value
 
     @property
     def origin(self):
-        """(4,4) float : The pose of the inertials relative to the link frame.
-        """
+        """(4,4) float : The pose of the inertials relative to the link frame."""
         return self._origin
 
     @origin.setter
@@ -1234,38 +1332,34 @@ def origin(self, value):
     @classmethod
     def _from_xml(cls, node, path):
         origin = parse_origin(node)
-        mass = float(node.find('mass').attrib['value'])
-        n = node.find('inertia')
-        xx = float(n.attrib['ixx'])
-        xy = float(n.attrib['ixy'])
-        xz = float(n.attrib['ixz'])
-        yy = float(n.attrib['iyy'])
-        yz = float(n.attrib['iyz'])
-        zz = float(n.attrib['izz'])
-        inertia = np.array([
-            [xx, xy, xz],
-            [xy, yy, yz],
-            [xz, yz, zz]
-        ], dtype=np.float64)
+        mass = float(node.find("mass").attrib["value"])
+        n = node.find("inertia")
+        xx = float(n.attrib["ixx"])
+        xy = float(n.attrib["ixy"])
+        xz = float(n.attrib["ixz"])
+        yy = float(n.attrib["iyy"])
+        yz = float(n.attrib["iyz"])
+        zz = float(n.attrib["izz"])
+        inertia = np.array([[xx, xy, xz], [xy, yy, yz], [xz, yz, zz]], dtype=np.float64)
         return Inertial(mass=mass, inertia=inertia, origin=origin)
 
     def _to_xml(self, parent, path):
-        node = ET.Element('inertial')
+        node = ET.Element("inertial")
         node.append(unparse_origin(self.origin))
-        mass = ET.Element('mass')
-        mass.attrib['value'] = str(self.mass)
+        mass = ET.Element("mass")
+        mass.attrib["value"] = str(self.mass)
         node.append(mass)
-        inertia = ET.Element('inertia')
-        inertia.attrib['ixx'] = str(self.inertia[0,0])
-        inertia.attrib['ixy'] = str(self.inertia[0,1])
-        inertia.attrib['ixz'] = str(self.inertia[0,2])
-        inertia.attrib['iyy'] = str(self.inertia[1,1])
-        inertia.attrib['iyz'] = str(self.inertia[1,2])
-        inertia.attrib['izz'] = str(self.inertia[2,2])
+        inertia = ET.Element("inertia")
+        inertia.attrib["ixx"] = str(self.inertia[0, 0])
+        inertia.attrib["ixy"] = str(self.inertia[0, 1])
+        inertia.attrib["ixz"] = str(self.inertia[0, 2])
+        inertia.attrib["iyy"] = str(self.inertia[1, 1])
+        inertia.attrib["iyz"] = str(self.inertia[1, 2])
+        inertia.attrib["izz"] = str(self.inertia[2, 2])
         node.append(inertia)
         return node
 
-    def copy(self, prefix='', mass=None, origin=None, inertia=None):
+    def copy(self, prefix="", mass=None, origin=None, inertia=None):
         """Create a deep copy of the visual with the prefix applied to all names.
 
         Parameters
@@ -1308,11 +1402,9 @@ class JointCalibration(URDFType):
         When the joint moves in a positive direction, this position will
         trigger a falling edge.
     """
-    _ATTRIBS = {
-        'rising': (float, False),
-        'falling': (float, False)
-    }
-    _TAG = 'calibration'
+
+    _ATTRIBS = {"rising": (float, False), "falling": (float, False)}
+    _TAG = "calibration"
 
     def __init__(self, rising=None, falling=None):
         self.rising = rising
@@ -1320,8 +1412,7 @@ def __init__(self, rising=None, falling=None):
 
     @property
     def rising(self):
-        """float : description.
-        """
+        """float : description."""
         return self._rising
 
     @rising.setter
@@ -1332,8 +1423,7 @@ def rising(self, value):
 
     @property
     def falling(self):
-        """float : description.
-        """
+        """float : description."""
         return self._falling
 
     @falling.setter
@@ -1342,7 +1432,7 @@ def falling(self, value):
             value = float(value)
         self._falling = value
 
-    def copy(self, prefix='', scale=None):
+    def copy(self, prefix="", scale=None):
         """Create a deep copy of the visual with the prefix applied to all names.
 
         Parameters
@@ -1373,11 +1463,12 @@ class JointDynamics(URDFType):
         The static friction value of the joint (N for prismatic joints,
         Nm for revolute).
     """
+
     _ATTRIBS = {
-        'damping': (float, False),
-        'friction': (float, False),
+        "damping": (float, False),
+        "friction": (float, False),
     }
-    _TAG = 'dynamics'
+    _TAG = "dynamics"
 
     def __init__(self, damping, friction):
         self.damping = damping
@@ -1385,8 +1476,7 @@ def __init__(self, damping, friction):
 
     @property
     def damping(self):
-        """float : The damping value of the joint.
-        """
+        """float : The damping value of the joint."""
         return self._damping
 
     @damping.setter
@@ -1397,8 +1487,7 @@ def damping(self, value):
 
     @property
     def friction(self):
-        """float : The static friction value of the joint.
-        """
+        """float : The static friction value of the joint."""
         return self._friction
 
     @friction.setter
@@ -1407,7 +1496,7 @@ def friction(self, value):
             value = float(value)
         self._friction = value
 
-    def copy(self, prefix='', scale=None):
+    def copy(self, prefix="", scale=None):
         """Create a deep copy of the visual with the prefix applied to all names.
 
         Parameters
@@ -1443,12 +1532,12 @@ class JointLimit(URDFType):
     """
 
     _ATTRIBS = {
-        'effort': (float, True),
-        'velocity': (float, True),
-        'lower': (float, False),
-        'upper': (float, False),
+        "effort": (float, True),
+        "velocity": (float, True),
+        "lower": (float, False),
+        "upper": (float, False),
     }
-    _TAG = 'limit'
+    _TAG = "limit"
 
     def __init__(self, effort, velocity, lower=None, upper=None):
         self.effort = effort
@@ -1458,8 +1547,7 @@ def __init__(self, effort, velocity, lower=None, upper=None):
 
     @property
     def effort(self):
-        """float : The maximum joint effort.
-        """
+        """float : The maximum joint effort."""
         return self._effort
 
     @effort.setter
@@ -1468,8 +1556,7 @@ def effort(self, value):
 
     @property
     def velocity(self):
-        """float : The maximum joint velocity.
-        """
+        """float : The maximum joint velocity."""
         return self._velocity
 
     @velocity.setter
@@ -1478,8 +1565,7 @@ def velocity(self, value):
 
     @property
     def lower(self):
-        """float : The lower joint limit.
-        """
+        """float : The lower joint limit."""
         return self._lower
 
     @lower.setter
@@ -1490,8 +1576,7 @@ def lower(self, value):
 
     @property
     def upper(self):
-        """float : The upper joint limit.
-        """
+        """float : The upper joint limit."""
         return self._upper
 
     @upper.setter
@@ -1500,7 +1585,7 @@ def upper(self, value):
             value = float(value)
         self._upper = value
 
-    def copy(self, prefix='', scale=None):
+    def copy(self, prefix="", scale=None):
         """Create a deep copy of the visual with the prefix applied to all names.
 
         Parameters
@@ -1537,12 +1622,13 @@ class JointMimic(URDFType):
     offset : float, optional
         The joint configuration offset. Defaults to 0.0.
     """
+
     _ATTRIBS = {
-        'joint': (str, True),
-        'multiplier': (float, False),
-        'offset': (float, False),
+        "joint": (str, True),
+        "multiplier": (float, False),
+        "offset": (float, False),
     }
-    _TAG = 'mimic'
+    _TAG = "mimic"
 
     def __init__(self, joint, multiplier=None, offset=None):
         self.joint = joint
@@ -1551,8 +1637,7 @@ def __init__(self, joint, multiplier=None, offset=None):
 
     @property
     def joint(self):
-        """float : The name of the joint to mimic.
-        """
+        """float : The name of the joint to mimic."""
         return self._joint
 
     @joint.setter
@@ -1561,8 +1646,7 @@ def joint(self, value):
 
     @property
     def multiplier(self):
-        """float : The multiplier for the joint configuration.
-        """
+        """float : The multiplier for the joint configuration."""
         return self._multiplier
 
     @multiplier.setter
@@ -1575,8 +1659,7 @@ def multiplier(self, value):
 
     @property
     def offset(self):
-        """float : The offset for the joint configuration
-        """
+        """float : The offset for the joint configuration"""
         return self._offset
 
     @offset.setter
@@ -1587,7 +1670,7 @@ def offset(self, value):
             value = 0.0
         self._offset = value
 
-    def copy(self, prefix='', scale=None):
+    def copy(self, prefix="", scale=None):
         """Create a deep copy of the joint mimic with the prefix applied to all names.
 
         Parameters
@@ -1601,9 +1684,9 @@ def copy(self, prefix='', scale=None):
             A deep copy of the joint mimic.
         """
         return JointMimic(
-            joint='{}{}'.format(prefix, self.joint),
+            joint="{}{}".format(prefix, self.joint),
             multiplier=self.multiplier,
-            offset=self.offset
+            offset=self.offset,
         )
 
 
@@ -1625,16 +1708,18 @@ class SafetyController(URDFType):
         The upper joint boundary where the safety controller kicks in.
         Defaults to 0.0.
     """
+
     _ATTRIBS = {
-        'k_velocity': (float, True),
-        'k_position': (float, False),
-        'soft_lower_limit': (float, False),
-        'soft_upper_limit': (float, False),
+        "k_velocity": (float, True),
+        "k_position": (float, False),
+        "soft_lower_limit": (float, False),
+        "soft_upper_limit": (float, False),
     }
-    _TAG = 'safety_controller'
+    _TAG = "safety_controller"
 
-    def __init__(self, k_velocity, k_position=None, soft_lower_limit=None,
-                 soft_upper_limit=None):
+    def __init__(
+        self, k_velocity, k_position=None, soft_lower_limit=None, soft_upper_limit=None
+    ):
         self.k_velocity = k_velocity
         self.k_position = k_position
         self.soft_lower_limit = soft_lower_limit
@@ -1642,8 +1727,7 @@ def __init__(self, k_velocity, k_position=None, soft_lower_limit=None,
 
     @property
     def soft_lower_limit(self):
-        """float : The soft lower limit where the safety controller kicks in.
-        """
+        """float : The soft lower limit where the safety controller kicks in."""
         return self._soft_lower_limit
 
     @soft_lower_limit.setter
@@ -1656,8 +1740,7 @@ def soft_lower_limit(self, value):
 
     @property
     def soft_upper_limit(self):
-        """float : The soft upper limit where the safety controller kicks in.
-        """
+        """float : The soft upper limit where the safety controller kicks in."""
         return self._soft_upper_limit
 
     @soft_upper_limit.setter
@@ -1670,8 +1753,7 @@ def soft_upper_limit(self, value):
 
     @property
     def k_position(self):
-        """float : A relation between the position and velocity limits.
-        """
+        """float : A relation between the position and velocity limits."""
         return self._k_position
 
     @k_position.setter
@@ -1684,15 +1766,14 @@ def k_position(self, value):
 
     @property
     def k_velocity(self):
-        """float : A relation between the effort and velocity limits.
-        """
+        """float : A relation between the effort and velocity limits."""
         return self._k_velocity
 
     @k_velocity.setter
     def k_velocity(self, value):
         self._k_velocity = float(value)
 
-    def copy(self, prefix='', scale=None):
+    def copy(self, prefix="", scale=None):
         """Create a deep copy of the visual with the prefix applied to all names.
 
         Parameters
@@ -1731,21 +1812,20 @@ class Actuator(URDFType):
     hardwareInterfaces : list of str, optional
         The supported hardware interfaces to the actuator.
     """
+
     _ATTRIBS = {
-        'name': (str, True),
+        "name": (str, True),
     }
-    _TAG = 'actuator'
+    _TAG = "actuator"
 
-    def __init__(self, name, mechanicalReduction=None,
-                 hardwareInterfaces=None):
+    def __init__(self, name, mechanicalReduction=None, hardwareInterfaces=None):
         self.name = name
         self.mechanicalReduction = mechanicalReduction
         self.hardwareInterfaces = hardwareInterfaces
 
     @property
     def name(self):
-        """str : The name of this actuator.
-        """
+        """str : The name of this actuator."""
         return self._name
 
     @name.setter
@@ -1754,8 +1834,7 @@ def name(self, value):
 
     @property
     def mechanicalReduction(self):
-        """str : A specifier for the type of mechanical reduction.
-        """
+        """str : A specifier for the type of mechanical reduction."""
         return self._mechanicalReduction
 
     @mechanicalReduction.setter
@@ -1766,8 +1845,7 @@ def mechanicalReduction(self, value):
 
     @property
     def hardwareInterfaces(self):
-        """list of str : The supported hardware interfaces.
-        """
+        """list of str : The supported hardware interfaces."""
         return self._hardwareInterfaces
 
     @hardwareInterfaces.setter
@@ -1783,30 +1861,30 @@ def hardwareInterfaces(self, value):
     @classmethod
     def _from_xml(cls, node, path):
         kwargs = cls._parse(node, path)
-        mr = node.find('mechanicalReduction')
+        mr = node.find("mechanicalReduction")
         if mr is not None:
             mr = float(mr.text)
-        kwargs['mechanicalReduction'] = mr
-        hi = node.findall('hardwareInterface')
+        kwargs["mechanicalReduction"] = mr
+        hi = node.findall("hardwareInterface")
         if len(hi) > 0:
             hi = [h.text for h in hi]
-        kwargs['hardwareInterfaces'] = hi
+        kwargs["hardwareInterfaces"] = hi
         return Actuator(**kwargs)
 
     def _to_xml(self, parent, path):
         node = self._unparse(path)
         if self.mechanicalReduction is not None:
-            mr = ET.Element('mechanicalReduction')
+            mr = ET.Element("mechanicalReduction")
             mr.text = str(self.mechanicalReduction)
             node.append(mr)
         if len(self.hardwareInterfaces) > 0:
             for hi in self.hardwareInterfaces:
-                h = ET.Element('hardwareInterface')
+                h = ET.Element("hardwareInterface")
                 h.text = hi
                 node.append(h)
         return node
 
-    def copy(self, prefix='', scale=None):
+    def copy(self, prefix="", scale=None):
         """Create a deep copy of the visual with the prefix applied to all names.
 
         Parameters
@@ -1820,7 +1898,7 @@ def copy(self, prefix='', scale=None):
             A deep copy of the visual.
         """
         return Actuator(
-            name='{}{}'.format(prefix, self.name),
+            name="{}{}".format(prefix, self.name),
             mechanicalReduction=self.mechanicalReduction,
             hardwareInterfaces=self.hardwareInterfaces.copy(),
         )
@@ -1836,10 +1914,11 @@ class TransmissionJoint(URDFType):
     hardwareInterfaces : list of str, optional
         The supported hardware interfaces to the actuator.
     """
+
     _ATTRIBS = {
-        'name': (str, True),
+        "name": (str, True),
     }
-    _TAG = 'joint'
+    _TAG = "joint"
 
     def __init__(self, name, hardwareInterfaces):
         self.name = name
@@ -1847,8 +1926,7 @@ def __init__(self, name, hardwareInterfaces):
 
     @property
     def name(self):
-        """str : The name of this transmission joint.
-        """
+        """str : The name of this transmission joint."""
         return self._name
 
     @name.setter
@@ -1857,8 +1935,7 @@ def name(self, value):
 
     @property
     def hardwareInterfaces(self):
-        """list of str : The supported hardware interfaces.
-        """
+        """list of str : The supported hardware interfaces."""
         return self._hardwareInterfaces
 
     @hardwareInterfaces.setter
@@ -1874,22 +1951,22 @@ def hardwareInterfaces(self, value):
     @classmethod
     def _from_xml(cls, node, path):
         kwargs = cls._parse(node, path)
-        hi = node.findall('hardwareInterface')
+        hi = node.findall("hardwareInterface")
         if len(hi) > 0:
             hi = [h.text for h in hi]
-        kwargs['hardwareInterfaces'] = hi
+        kwargs["hardwareInterfaces"] = hi
         return TransmissionJoint(**kwargs)
 
     def _to_xml(self, parent, path):
         node = self._unparse(path)
         if len(self.hardwareInterfaces) > 0:
             for hi in self.hardwareInterfaces:
-                h = ET.Element('hardwareInterface')
+                h = ET.Element("hardwareInterface")
                 h.text = hi
                 node.append(h)
         return node
 
-    def copy(self, prefix='', scale=None):
+    def copy(self, prefix="", scale=None):
         """Create a deep copy with the prefix applied to all names.
 
         Parameters
@@ -1903,7 +1980,7 @@ def copy(self, prefix='', scale=None):
             A deep copy.
         """
         return TransmissionJoint(
-            name='{}{}'.format(prefix, self.name),
+            name="{}{}".format(prefix, self.name),
             hardwareInterfaces=self.hardwareInterfaces.copy(),
         )
 
@@ -1928,14 +2005,15 @@ class Transmission(URDFType):
     actuators : list of :class:`.Actuator`
         The actuators connected to this transmission.
     """
+
     _ATTRIBS = {
-        'name': (str, True),
+        "name": (str, True),
     }
     _ELEMENTS = {
-        'joints': (TransmissionJoint, True, True),
-        'actuators': (Actuator, True, True),
+        "joints": (TransmissionJoint, True, True),
+        "actuators": (Actuator, True, True),
     }
-    _TAG = 'transmission'
+    _TAG = "transmission"
 
     def __init__(self, name, trans_type, joints=None, actuators=None):
         self.name = name
@@ -1945,8 +2023,7 @@ def __init__(self, name, trans_type, joints=None, actuators=None):
 
     @property
     def name(self):
-        """str : The name of this transmission.
-        """
+        """str : The name of this transmission."""
         return self._name
 
     @name.setter
@@ -1955,8 +2032,7 @@ def name(self, value):
 
     @property
     def trans_type(self):
-        """str : The type of this transmission.
-        """
+        """str : The type of this transmission."""
         return self._trans_type
 
     @trans_type.setter
@@ -1978,15 +2054,12 @@ def joints(self, value):
             value = list(value)
             for v in value:
                 if not isinstance(v, TransmissionJoint):
-                    raise TypeError(
-                        'Joints expects a list of TransmissionJoint'
-                    )
+                    raise TypeError("Joints expects a list of TransmissionJoint")
         self._joints = value
 
     @property
     def actuators(self):
-        """:class:`.Actuator` : The actuators the transmission is connected to.
-        """
+        """:class:`.Actuator` : The actuators the transmission is connected to."""
         return self._actuators
 
     @actuators.setter
@@ -1997,25 +2070,23 @@ def actuators(self, value):
             value = list(value)
             for v in value:
                 if not isinstance(v, Actuator):
-                    raise TypeError(
-                        'Actuators expects a list of Actuator'
-                    )
+                    raise TypeError("Actuators expects a list of Actuator")
         self._actuators = value
 
     @classmethod
     def _from_xml(cls, node, path):
         kwargs = cls._parse(node, path)
-        kwargs['trans_type'] = node.find('type').text
+        kwargs["trans_type"] = node.find("type").text
         return Transmission(**kwargs)
 
     def _to_xml(self, parent, path):
         node = self._unparse(path)
-        ttype = ET.Element('type')
+        ttype = ET.Element("type")
         ttype.text = self.trans_type
         node.append(ttype)
         return node
 
-    def copy(self, prefix='', scale=None):
+    def copy(self, prefix="", scale=None):
         """Create a deep copy with the prefix applied to all names.
 
         Parameters
@@ -2029,7 +2100,7 @@ def copy(self, prefix='', scale=None):
             A deep copy.
         """
         return Transmission(
-            name='{}{}'.format(prefix, self.name),
+            name="{}{}".format(prefix, self.name),
             trans_type=self.trans_type,
             joints=[j.copy(prefix) for j in self.joints],
             actuators=[a.copy(prefix) for a in self.actuators],
@@ -2080,23 +2151,34 @@ class Joint(URDFType):
     mimic : :class:`JointMimic`, optional
         Joint mimicry information.
     """
-    TYPES = ['fixed', 'prismatic', 'revolute',
-             'continuous', 'floating', 'planar']
+
+    TYPES = ["fixed", "prismatic", "revolute", "continuous", "floating", "planar"]
     _ATTRIBS = {
-        'name': (str, True),
+        "name": (str, True),
     }
     _ELEMENTS = {
-        'dynamics': (JointDynamics, False, False),
-        'limit': (JointLimit, False, False),
-        'mimic': (JointMimic, False, False),
-        'safety_controller': (SafetyController, False, False),
-        'calibration': (JointCalibration, False, False),
+        "dynamics": (JointDynamics, False, False),
+        "limit": (JointLimit, False, False),
+        "mimic": (JointMimic, False, False),
+        "safety_controller": (SafetyController, False, False),
+        "calibration": (JointCalibration, False, False),
     }
-    _TAG = 'joint'
-
-    def __init__(self, name, joint_type, parent, child, axis=None, origin=None,
-                 limit=None, dynamics=None, safety_controller=None,
-                 calibration=None, mimic=None):
+    _TAG = "joint"
+
+    def __init__(
+        self,
+        name,
+        joint_type,
+        parent,
+        child,
+        axis=None,
+        origin=None,
+        limit=None,
+        dynamics=None,
+        safety_controller=None,
+        calibration=None,
+        mimic=None,
+    ):
         self.name = name
         self.parent = parent
         self.child = child
@@ -2111,8 +2193,7 @@ def __init__(self, name, joint_type, parent, child, axis=None, origin=None,
 
     @property
     def name(self):
-        """str : Name for this joint.
-        """
+        """str : Name for this joint."""
         return self._name
 
     @name.setter
@@ -2121,21 +2202,19 @@ def name(self, value):
 
     @property
     def joint_type(self):
-        """str : The type of this joint.
-        """
+        """str : The type of this joint."""
         return self._joint_type
 
     @joint_type.setter
     def joint_type(self, value):
         value = str(value)
         if value not in Joint.TYPES:
-            raise ValueError('Unsupported joint type {}'.format(value))
+            raise ValueError("Unsupported joint type {}".format(value))
         self._joint_type = value
 
     @property
     def parent(self):
-        """str : The name of the parent link.
-        """
+        """str : The name of the parent link."""
         return self._parent
 
     @parent.setter
@@ -2144,8 +2223,7 @@ def parent(self, value):
 
     @property
     def child(self):
-        """str : The name of the child link.
-        """
+        """str : The name of the child link."""
         return self._child
 
     @child.setter
@@ -2154,8 +2232,7 @@ def child(self, value):
 
     @property
     def axis(self):
-        """(3,) float : The joint axis in the joint frame.
-        """
+        """(3,) float : The joint axis in the joint frame."""
         return self._axis
 
     @axis.setter
@@ -2167,7 +2244,7 @@ def axis(self, value):
         else:
             value = np.asanyarray(value, dtype=np.float64)
             if value.shape != (3,):
-                raise ValueError('Invalid shape for axis, should be (3,)')
+                raise ValueError("Invalid shape for axis, should be (3,)")
             value = value / np.linalg.norm(value)
         self._axis = value
 
@@ -2184,70 +2261,66 @@ def origin(self, value):
 
     @property
     def limit(self):
-        """:class:`.JointLimit` : The limits for this joint.
-        """
+        """:class:`.JointLimit` : The limits for this joint."""
         return self._limit
 
     @limit.setter
     def limit(self, value):
         if value is None:
-            if self.joint_type in ['prismatic', 'revolute']:
-                raise ValueError('Require joint limit for prismatic and '
-                                 'revolute joints')
+            if self.joint_type in ["prismatic", "revolute"]:
+                raise ValueError(
+                    "Require joint limit for prismatic and " "revolute joints"
+                )
         elif not isinstance(value, JointLimit):
-            raise TypeError('Expected JointLimit type')
+            raise TypeError("Expected JointLimit type")
         self._limit = value
 
     @property
     def dynamics(self):
-        """:class:`.JointDynamics` : The dynamics for this joint.
-        """
+        """:class:`.JointDynamics` : The dynamics for this joint."""
         return self._dynamics
 
     @dynamics.setter
     def dynamics(self, value):
         if value is not None:
             if not isinstance(value, JointDynamics):
-                raise TypeError('Expected JointDynamics type')
+                raise TypeError("Expected JointDynamics type")
         self._dynamics = value
 
     @property
     def safety_controller(self):
-        """:class:`.SafetyController` : The safety controller for this joint.
-        """
+        """:class:`.SafetyController` : The safety controller for this joint."""
         return self._safety_controller
 
     @safety_controller.setter
     def safety_controller(self, value):
         if value is not None:
             if not isinstance(value, SafetyController):
-                raise TypeError('Expected SafetyController type')
+                raise TypeError("Expected SafetyController type")
         self._safety_controller = value
 
     @property
     def calibration(self):
-        """:class:`.JointCalibration` : The calibration for this joint.
-        """
+        """:class:`.JointCalibration` : The calibration for this joint."""
         return self._calibration
 
     @calibration.setter
     def calibration(self, value):
         if value is not None:
             if not isinstance(value, JointCalibration):
-                raise TypeError('Expected JointCalibration type')
+                raise TypeError("Expected JointCalibration type")
         self._calibration = value
 
     @property
     def mimic(self):
-        """:class:`.JointMimic` : The mimic for this joint.
-        """
+        """:class:`.JointMimic` : The mimic for this joint."""
         return self._mimic
 
     @mimic.setter
     def mimic(self, value):
         if value is not None:
             if not isinstance(value, JointMimic):
-                raise TypeError('Expected JointMimic type')
+                raise TypeError("Expected JointMimic type")
         self._mimic = value
 
     def is_valid(self, cfg):
@@ -2263,7 +2336,7 @@ def is_valid(self, cfg):
         is_valid : bool
             True if the configuration is valid, and False otherwise.
         """
-        if self.joint_type not in ['fixed', 'revolute']:
+        if self.joint_type not in ["fixed", "revolute"]:
             return True
         if self.joint_limit is None:
             return True
@@ -2274,7 +2347,7 @@ def is_valid(self, cfg):
             lower = self.limit.lower
         if self.limit.upper is not None:
             upper = self.limit.upper
-        return (cfg >= lower and cfg <= upper)
+        return cfg >= lower and cfg <= upper
 
     def get_child_pose(self, cfg=None):
         """Computes the child pose relative to a parent pose for a given
@@ -2303,48 +2376,46 @@ def get_child_pose(self, cfg=None):
         """
         if cfg is None:
             return self.origin
-        elif self.joint_type == 'fixed':
+        elif self.joint_type == "fixed":
             return self.origin
-        elif self.joint_type in ['revolute', 'continuous']:
+        elif self.joint_type in ["revolute", "continuous"]:
             if cfg is None:
                 cfg = 0.0
             else:
                 cfg = float(cfg)
             R = trimesh.transformations.rotation_matrix(cfg, self.axis)
             return self.origin.dot(R)
-        elif self.joint_type == 'prismatic':
+        elif self.joint_type == "prismatic":
             if cfg is None:
                 cfg = 0.0
             else:
                 cfg = float(cfg)
             translation = np.eye(4, dtype=np.float64)
-            translation[:3,3] = self.axis * cfg
+            translation[:3, 3] = self.axis * cfg
             return self.origin.dot(translation)
-        elif self.joint_type == 'planar':
+        elif self.joint_type == "planar":
             if cfg is None:
                 cfg = np.zeros(2, dtype=np.float64)
             else:
                 cfg = np.asanyarray(cfg, dtype=np.float64)
             if cfg.shape != (2,):
-                raise ValueError(
-                    '(2,) float configuration required for planar joints'
-                )
+                raise ValueError("(2,) float configuration required for planar joints")
             translation = np.eye(4, dtype=np.float64)
-            translation[:3,3] = self.origin[:3,:2].dot(cfg)
+            translation[:3, 3] = self.origin[:3, :2].dot(cfg)
             return self.origin.dot(translation)
-        elif self.joint_type == 'floating':
+        elif self.joint_type == "floating":
             if cfg is None:
                 cfg = np.zeros(6, dtype=np.float64)
             else:
                 cfg = configure_origin(cfg)
             if cfg is None:
-                raise ValueError('Invalid configuration for floating joint')
+                raise ValueError("Invalid configuration for floating joint")
             return self.origin.dot(cfg)
         else:
-            raise ValueError('Invalid configuration')
+            raise ValueError("Invalid configuration")
 
     def get_child_poses(self, cfg, n_cfgs):
-        """Computes the child pose relative to a parent pose for a given set of 
+        """Computes the child pose relative to a parent pose for a given set of
         configuration values.
 
         Parameters
@@ -2369,52 +2440,52 @@ def get_child_poses(self, cfg, n_cfgs):
         """
         if cfg is None:
             return np.tile(self.origin, (n_cfgs, 1, 1))
-        elif self.joint_type == 'fixed':
+        elif self.joint_type == "fixed":
             return np.tile(self.origin, (n_cfgs, 1, 1))
-        elif self.joint_type in ['revolute', 'continuous']:
+        elif self.joint_type in ["revolute", "continuous"]:
             if cfg is None:
                 cfg = np.zeros(n_cfgs)
             return np.matmul(self.origin, self._rotation_matrices(cfg, self.axis))
-        elif self.joint_type == 'prismatic':
+        elif self.joint_type == "prismatic":
             if cfg is None:
                 cfg = np.zeros(n_cfgs)
             translation = np.tile(np.eye(4), (n_cfgs, 1, 1))
-            translation[:,:3,3] = self.axis * cfg[:,np.newaxis]
+            translation[:, :3, 3] = self.axis * cfg[:, np.newaxis]
             return np.matmul(self.origin, translation)
-        elif self.joint_type == 'planar':
+        elif self.joint_type == "planar":
             raise NotImplementedError()
-        elif self.joint_type == 'floating':
+        elif self.joint_type == "floating":
             raise NotImplementedError()
         else:
-            raise ValueError('Invalid configuration')
+            raise ValueError("Invalid configuration")
 
     @classmethod
     def _from_xml(cls, node, path):
         kwargs = cls._parse(node, path)
-        kwargs['joint_type'] = str(node.attrib['type'])
-        kwargs['parent'] = node.find('parent').attrib['link']
-        kwargs['child'] = node.find('child').attrib['link']
-        axis = node.find('axis')
+        kwargs["joint_type"] = str(node.attrib["type"])
+        kwargs["parent"] = node.find("parent").attrib["link"]
+        kwargs["child"] = node.find("child").attrib["link"]
+        axis = node.find("axis")
         if axis is not None:
-            axis = np.fromstring(axis.attrib['xyz'], sep=' ')
-        kwargs['axis'] = axis
-        kwargs['origin'] = parse_origin(node)
+            axis = np.fromstring(axis.attrib["xyz"], sep=" ")
+        kwargs["axis"] = axis
+        kwargs["origin"] = parse_origin(node)
         return Joint(**kwargs)
 
     def _to_xml(self, parent, path):
         node = self._unparse(path)
-        parent = ET.Element('parent')
-        parent.attrib['link'] = self.parent
+        parent = ET.Element("parent")
+        parent.attrib["link"] = self.parent
         node.append(parent)
-        child = ET.Element('child')
-        child.attrib['link'] = self.child
+        child = ET.Element("child")
+        child.attrib["link"] = self.child
         node.append(child)
         if self.axis is not None:
-            axis = ET.Element('axis')
-            axis.attrib['xyz'] = np.array2string(self.axis)[1:-1]
+            axis = ET.Element("axis")
+            axis.attrib["xyz"] = np.array2string(self.axis)[1:-1]
             node.append(axis)
         node.append(unparse_origin(self.origin))
-        node.attrib['type'] = self.joint_type
+        node.attrib["type"] = self.joint_type
         return node
 
     def _rotation_matrices(self, angles, axis):
@@ -2436,21 +2507,29 @@ def _rotation_matrices(self, angles, axis):
         sina = np.sin(angles)
         cosa = np.cos(angles)
         M = np.tile(np.eye(4), (len(angles), 1, 1))
-        M[:,0,0] = cosa
-        M[:,1,1] = cosa
-        M[:,2,2] = cosa
-        M[:,:3,:3] += (
-            np.tile(np.outer(axis, axis), (len(angles), 1, 1)) *
-            (1.0 - cosa)[:, np.newaxis, np.newaxis]
+        M[:, 0, 0] = cosa
+        M[:, 1, 1] = cosa
+        M[:, 2, 2] = cosa
+        M[:, :3, :3] += (
+            np.tile(np.outer(axis, axis), (len(angles), 1, 1))
+            * (1.0 - cosa)[:, np.newaxis, np.newaxis]
+        )
+        M[:, :3, :3] += (
+            np.tile(
+                np.array(
+                    [
+                        [0.0, -axis[2], axis[1]],
+                        [axis[2], 0.0, -axis[0]],
+                        [-axis[1], axis[0], 0.0],
+                    ]
+                ),
+                (len(angles), 1, 1),
+            )
+            * sina[:, np.newaxis, np.newaxis]
         )
-        M[:,:3,:3] += np.tile(np.array([
-            [0.0, -axis[2], axis[1]],
-            [axis[2], 0.0, -axis[0]],
-            [-axis[1], axis[0], 0.0]]
-        ), (len(angles), 1, 1)) * sina[:, np.newaxis, np.newaxis]
         return M
 
-    def copy(self, prefix='', scale=None):
+    def copy(self, prefix="", scale=None):
         """Create a deep copy of the joint with the prefix applied to all names.
 
         Parameters
@@ -2467,25 +2546,30 @@ def copy(self, prefix='', scale=None):
         if scale is not None:
             if not isinstance(scale, (list, np.ndarray)):
                 scale = np.repeat(scale, 3)
-            origin[:3,3] *= scale
+            origin[:3, 3] *= scale
         cpy = Joint(
-            name='{}{}'.format(prefix, self.name),
+            name="{}{}".format(prefix, self.name),
             joint_type=self.joint_type,
-            parent='{}{}'.format(prefix, self.parent),
-            child='{}{}'.format(prefix, self.child),
+            parent="{}{}".format(prefix, self.parent),
+            child="{}{}".format(prefix, self.child),
             axis=self.axis.copy(),
             origin=origin,
             limit=(self.limit.copy(prefix, scale) if self.limit else None),
-            dynamics=(self.dynamics.copy(prefix,scale) if self.dynamics else None),
-            safety_controller=(self.safety_controller.copy(prefix, scale) if
-                               self.safety_controller else None),
-            calibration=(self.calibration.copy(prefix, scale) if self.calibration else None),
-            mimic=(self.mimic.copy(prefix=prefix, scale=scale) if self.mimic else None)
+            dynamics=(self.dynamics.copy(prefix, scale) if self.dynamics else None),
+            safety_controller=(
+                self.safety_controller.copy(prefix, scale)
+                if self.safety_controller
+                else None
+            ),
+            calibration=(
+                self.calibration.copy(prefix, scale) if self.calibration else None
+            ),
+            mimic=(self.mimic.copy(prefix=prefix, scale=scale) if self.mimic else None),
         )
         return cpy
 
 
-class Link(URDFType):
+class Link(URDFTypeWithMesh):
     """A link of a rigid object.
 
     Parameters
@@ -2501,14 +2585,14 @@ class Link(URDFType):
     """
 
     _ATTRIBS = {
-        'name': (str, True),
+        "name": (str, True),
     }
     _ELEMENTS = {
-        'inertial': (Inertial, False, False),
-        'visuals': (Visual, False, True),
-        'collisions': (Collision, False, True),
+        "inertial": (Inertial, False, False),
+        "visuals": (Visual, False, True),
+        "collisions": (Collision, False, True),
     }
-    _TAG = 'link'
+    _TAG = "link"
 
     def __init__(self, name, inertial, visuals, collisions):
         self.name = name
@@ -2520,8 +2604,7 @@ def __init__(self, name, inertial, visuals, collisions):
 
     @property
     def name(self):
-        """str : The name of this link.
-        """
+        """str : The name of this link."""
         return self._name
 
     @name.setter
@@ -2530,14 +2613,13 @@ def name(self, value):
 
     @property
     def inertial(self):
-        """:class:`.Inertial` : Inertial properties of the link.
-        """
+        """:class:`.Inertial` : Inertial properties of the link."""
         return self._inertial
 
     @inertial.setter
     def inertial(self, value):
         if value is not None and not isinstance(value, Inertial):
-            raise TypeError('Expected Inertial object')
+            raise TypeError("Expected Inertial object")
         # Set default inertial
         if value is None:
             value = Inertial(mass=1.0, inertia=np.eye(3))
@@ -2545,8 +2627,7 @@ def inertial(self, value):
 
     @property
     def visuals(self):
-        """list of :class:`.Visual` : The visual properties of this link.
-        """
+        """list of :class:`.Visual` : The visual properties of this link."""
         return self._visuals
 
     @visuals.setter
@@ -2557,13 +2638,12 @@ def visuals(self, value):
             value = list(value)
             for v in value:
                 if not isinstance(v, Visual):
-                    raise ValueError('Expected list of Visual objects')
+                    raise ValueError("Expected list of Visual objects")
         self._visuals = value
 
     @property
     def collisions(self):
-        """list of :class:`.Collision` : The collision properties of this link.
-        """
+        """list of :class:`.Collision` : The collision properties of this link."""
         return self._collisions
 
     @collisions.setter
@@ -2574,7 +2654,7 @@ def collisions(self, value):
             value = list(value)
             for v in value:
                 if not isinstance(v, Collision):
-                    raise ValueError('Expected list of Collision objects')
+                    raise ValueError("Expected list of Collision objects")
         self._collisions = value
 
     @property
@@ -2593,16 +2673,16 @@ def collision_mesh(self):
                     if c.geometry.mesh is not None:
                         if c.geometry.mesh.scale is not None:
                             S = np.eye(4)
-                            S[:3,:3] = np.diag(c.geometry.mesh.scale)
+                            S[:3, :3] = np.diag(c.geometry.mesh.scale)
                             pose = pose.dot(S)
                     m.apply_transform(pose)
                     meshes.append(m)
             if len(meshes) == 0:
                 return None
-            self._collision_mesh = (meshes[0] + meshes[1:])
+            self._collision_mesh = meshes[0] + meshes[1:]
         return self._collision_mesh
 
-    def copy(self, prefix='', scale=None, collision_only=False):
+    def copy(self, prefix="", scale=None, collision_only=False):
         """Create a deep copy of the link.
 
         Parameters
@@ -2622,21 +2702,20 @@ def copy(self, prefix='', scale=None, collision_only=False):
                 sm = np.eye(4)
                 if not isinstance(scale, (list, np.ndarray)):
                     scale = np.repeat(scale, 3)
-                sm[:3,:3] = np.diag(scale)
+                sm[:3, :3] = np.diag(scale)
                 cm = self.collision_mesh.copy()
                 cm.density = self.inertial.mass / cm.volume
                 cm.apply_transform(sm)
                 cmm = np.eye(4)
-                cmm[:3,3] = cm.center_mass
-                inertial = Inertial(mass=cm.mass, inertia=cm.moment_inertia,
-                                    origin=cmm)
+                cmm[:3, 3] = cm.center_mass
+                inertial = Inertial(mass=cm.mass, inertia=cm.moment_inertia, origin=cmm)
 
         visuals = None
         if not collision_only:
-            visuals=[v.copy(prefix=prefix, scale=scale) for v in self.visuals]
+            visuals = [v.copy(prefix=prefix, scale=scale) for v in self.visuals]
 
         cpy = Link(
-            name='{}{}'.format(prefix, self.name),
+            name="{}{}".format(prefix, self.name),
             inertial=inertial,
             visuals=visuals,
             collisions=[v.copy(prefix=prefix, scale=scale) for v in self.collisions],
@@ -2645,7 +2724,7 @@ def copy(self, prefix='', scale=None, collision_only=False):
         return cpy
 
 
-class URDF(URDFType):
+class URDF(URDFTypeWithMesh):
     """The top-level URDF specification.
 
     The URDF encapsulates an articulated object, such as a robot or a gripper.
@@ -2667,19 +2746,27 @@ class URDF(URDFType):
     other_xml : str, optional
         A string containing any extra XML for extensions.
     """
+
     _ATTRIBS = {
-        'name': (str, True),
+        "name": (str, True),
     }
     _ELEMENTS = {
-        'links': (Link, True, True),
-        'joints': (Joint, False, True),
-        'transmissions': (Transmission, False, True),
-        'materials': (Material, False, True),
+        "links": (Link, True, True),
+        "joints": (Joint, False, True),
+        "transmissions": (Transmission, False, True),
+        "materials": (Material, False, True),
     }
-    _TAG = 'robot'
-
-    def __init__(self, name, links, joints=None,
-                 transmissions=None, materials=None, other_xml=None):
+    _TAG = "robot"
+
+    def __init__(
+        self,
+        name,
+        links,
+        joints=None,
+        transmissions=None,
+        materials=None,
+        other_xml=None,
+    ):
         if joints is None:
             joints = []
         if transmissions is None:
@@ -2704,25 +2791,24 @@ def __init__(self, name, links, joints=None,
 
         for x in self._links:
             if x.name in self._link_map:
-                raise ValueError('Two links with name {} found'.format(x.name))
+                raise ValueError("Two links with name {} found".format(x.name))
             self._link_map[x.name] = x
 
         for x in self._joints:
             if x.name in self._joint_map:
-                raise ValueError('Two joints with name {} '
-                                 'found'.format(x.name))
+                raise ValueError("Two joints with name {} " "found".format(x.name))
             self._joint_map[x.name] = x
 
         for x in self._transmissions:
             if x.name in self._transmission_map:
-                raise ValueError('Two transmissions with name {} '
-                                 'found'.format(x.name))
+                raise ValueError(
+                    "Two transmissions with name {} " "found".format(x.name)
+                )
             self._transmission_map[x.name] = x
 
         for x in self._materials:
             if x.name in self._material_map:
-                raise ValueError('Two materials with name {} '
-                                 'found'.format(x.name))
+                raise ValueError("Two materials with name {} " "found".format(x.name))
             self._material_map[x.name] = x
 
         # Synchronize materials between links and top-level set
@@ -2749,9 +2835,7 @@ def __init__(self, name, links, joints=None,
         self._base_link, self._end_links = self._validate_graph()
 
         # Cache the paths to the base link
-        self._paths_to_base = nx.shortest_path(
-            self._G, target=self._base_link
-        )
+        self._paths_to_base = nx.shortest_path(self._G, target=self._base_link)
 
         self._actuated_joints = self._sort_joints(actuated_joints)
 
@@ -2762,8 +2846,7 @@ def __init__(self, name, links, joints=None,
 
     @property
     def name(self):
-        """str : The name of the URDF.
-        """
+        """str : The name of the URDF."""
         return self._name
 
     @name.setter
@@ -2852,8 +2935,7 @@ def material_map(self):
 
     @property
     def other_xml(self):
-        """str : Any extra XML that belongs with the URDF.
-        """
+        """str : Any extra XML that belongs with the URDF."""
         return self._other_xml
 
     @other_xml.setter
@@ -2907,7 +2989,7 @@ def cfg_to_vector(self, cfg):
                     vec[i] = cfg[jn]
             return vec
         else:
-            raise ValueError('Invalid configuration: {}'.format(cfg))
+            raise ValueError("Invalid configuration: {}".format(cfg))
 
     @property
     def base_link(self):
@@ -2947,8 +3029,7 @@ def joint_limit_cfgs(self):
 
     @property
     def joint_limits(self):
-        """(n,2) float : A lower and upper limit for each joint.
-        """
+        """(n,2) float : A lower and upper limit for each joint."""
         limits = []
         for joint in self.actuated_joints:
             limit = [-np.infty, np.infty]
@@ -3005,8 +3086,9 @@ def link_fk(self, cfg=None, link=None, links=None, use_names=False):
                 elif isinstance(lnk, Link):
                     link_set.add(lnk)
                 else:
-                    raise TypeError('Got object of type {} in links list'
-                                    .format(type(lnk)))
+                    raise TypeError(
+                        "Got object of type {} in links list".format(type(lnk))
+                    )
         else:
             link_set = self.links
 
@@ -3020,7 +3102,7 @@ def link_fk(self, cfg=None, link=None, links=None, use_names=False):
             for i in range(len(path) - 1):
                 child = path[i]
                 parent = path[i + 1]
-                joint = self._G.get_edge_data(child, parent)['joint']
+                joint = self._G.get_edge_data(child, parent)["joint"]
 
                 cfg = None
                 if joint.mimic is not None:
@@ -3090,8 +3172,9 @@ def link_fk_batch(self, cfgs=None, link=None, links=None, use_names=False):
                 elif isinstance(lnk, Link):
                     link_set.add(lnk)
                 else:
-                    raise TypeError('Got object of type {} in links list'
-                                    .format(type(lnk)))
+                    raise TypeError(
+                        "Got object of type {} in links list".format(type(lnk))
+                    )
         else:
             link_set = self.links
 
@@ -3105,14 +3188,16 @@ def link_fk_batch(self, cfgs=None, link=None, links=None, use_names=False):
             for i in range(len(path) - 1):
                 child = path[i]
                 parent = path[i + 1]
-                joint = self._G.get_edge_data(child, parent)['joint']
+                joint = self._G.get_edge_data(child, parent)["joint"]
 
                 cfg_vals = None
                 if joint.mimic is not None:
                     mimic_joint = self._joint_map[joint.mimic.joint]
                     if mimic_joint in joint_cfgs:
                         cfg_vals = joint_cfgs[mimic_joint]
-                        cfg_vals = joint.mimic.multiplier * cfg_vals + joint.mimic.offset
+                        cfg_vals = (
+                            joint.mimic.multiplier * cfg_vals + joint.mimic.offset
+                        )
                 elif joint in joint_cfgs:
                     cfg_vals = joint_cfgs[joint]
                 poses = np.matmul(joint.get_child_poses(cfg_vals, n_cfgs), poses)
@@ -3226,7 +3311,7 @@ def visual_trimesh_fk(self, cfg=None, links=None):
                     if visual.geometry.mesh is not None:
                         if visual.geometry.mesh.scale is not None:
                             S = np.eye(4, dtype=np.float64)
-                            S[:3,:3] = np.diag(visual.geometry.mesh.scale)
+                            S[:3, :3] = np.diag(visual.geometry.mesh.scale)
                             pose = pose.dot(S)
                     fk[mesh] = pose
         return fk
@@ -3264,7 +3349,7 @@ def visual_trimesh_fk_batch(self, cfgs=None, links=None):
                     if visual.geometry.mesh is not None:
                         if visual.geometry.mesh.scale is not None:
                             S = np.eye(4, dtype=np.float64)
-                            S[:3,:3] = np.diag(visual.geometry.mesh.scale)
+                            S[:3, :3] = np.diag(visual.geometry.mesh.scale)
                             poses = np.matmul(poses, S)
                     fk[mesh] = poses
         return fk
@@ -3447,7 +3532,7 @@ def animate(self, cfg_trajectory=None, loop_time=3.0, use_collision=False):
         ct = cfg_trajectory
 
         traj_len = None  # Length of the trajectory in steps
-        ct_np = {}       # Numpyified trajectory
+        ct_np = {}  # Numpyified trajectory
 
         # If trajectory not specified, articulate between the limits.
         if ct is None:
@@ -3464,18 +3549,19 @@ def animate(self, cfg_trajectory=None, loop_time=3.0, use_collision=False):
                     if traj_len is None:
                         traj_len = len(val)
                     elif traj_len != len(val):
-                        raise ValueError('Trajectories must be same length')
+                        raise ValueError("Trajectories must be same length")
                     ct_np[k] = val
         elif isinstance(ct, (list, tuple, np.ndarray)):
             ct = np.asanyarray(ct).astype(np.float64)
             if ct.ndim == 1:
                 ct = ct.reshape(-1, 1)
             if ct.ndim != 2 or ct.shape[1] != len(self.actuated_joints):
-                raise ValueError('Cfg trajectory must have entry for each joint')
-            ct_np = {j: ct[:,i] for i, j in enumerate(self.actuated_joints)}
+                raise ValueError("Cfg trajectory must have entry for each joint")
+            ct_np = {j: ct[:, i] for i, j in enumerate(self.actuated_joints)}
         else:
-            raise TypeError('Invalid type for cfg_trajectory: {}'
-                            .format(type(cfg_trajectory)))
+            raise TypeError(
+                "Invalid type for cfg_trajectory: {}".format(type(cfg_trajectory))
+            )
 
         # If there isn't a trajectory to render, just show the model and exit
         if len(ct_np) == 0 or traj_len < 2:
@@ -3494,14 +3580,15 @@ def animate(self, cfg_trajectory=None, loop_time=3.0, use_collision=False):
         # Compute alphas for each time
         right_inds = np.digitize(times, bins, right=True)
         right_inds[right_inds == 0] = 1
-        alphas = ((bins[right_inds] - times) /
-                  (bins[right_inds] - bins[right_inds - 1]))
+        alphas = (bins[right_inds] - times) / (bins[right_inds] - bins[right_inds - 1])
 
         # Create the new interpolated trajectory
         new_ct = {}
         for k in ct_np:
-            new_ct[k] = (alphas * ct_np[k][right_inds - 1] +
-                         (1.0 - alphas) * ct_np[k][right_inds])
+            new_ct[k] = (
+                alphas * ct_np[k][right_inds - 1]
+                + (1.0 - alphas) * ct_np[k][right_inds]
+            )
 
         # Create the scene
         if use_collision:
@@ -3521,9 +3608,9 @@ def animate(self, cfg_trajectory=None, loop_time=3.0, use_collision=False):
         blp = self.link_fk(links=[self.base_link])[self.base_link]
 
         # Pop the visualizer asynchronously
-        v = pyrender.Viewer(scene, run_in_thread=True,
-                            use_raymond_lighting=True,
-                            view_center=blp[:3,3])
+        v = pyrender.Viewer(
+            scene, run_in_thread=True, use_raymond_lighting=True, view_center=blp[:3, 3]
+        )
 
         # Now, run our loop
         i = 0
@@ -3573,7 +3660,7 @@ def show(self, cfg=None, use_collision=False):
             scene.add(mesh, pose=pose)
         pyrender.Viewer(scene, use_raymond_lighting=True)
 
-    def copy(self, name=None, prefix='', scale=None, collision_only=False):
+    def copy(self, name=None, prefix="", scale=None, collision_only=False):
         """Make a deep copy of the URDF.
 
         Parameters
@@ -3593,12 +3680,12 @@ def copy(self, name=None, prefix='', scale=None, collision_only=False):
             The copied URDF.
         """
         return URDF(
-            name = (name if name else self.name),
+            name=(name if name else self.name),
             links=[v.copy(prefix, scale, collision_only) for v in self.links],
             joints=[v.copy(prefix, scale) for v in self.joints],
             transmissions=[v.copy(prefix, scale) for v in self.transmissions],
             materials=[v.copy(prefix, scale) for v in self.materials],
-            other_xml=self.other_xml
+            other_xml=self.other_xml,
         )
 
     def save(self, file_obj):
@@ -3624,10 +3711,9 @@ def save(self, file_obj):
 
         node = self._to_xml(None, path)
         tree = ET.ElementTree(node)
-        tree.write(file_obj, pretty_print=True,
-                   xml_declaration=True, encoding='utf-8')
+        tree.write(file_obj, pretty_print=True, xml_declaration=True, encoding="utf-8")
 
-    def join(self, other, link, origin=None, name=None, prefix=''):
+    def join(self, other, link, origin=None, name=None, prefix=""):
         """Join another URDF to this one by rigidly fixturing the two at a link.
 
         Parameters
@@ -3657,12 +3743,12 @@ def join(self, other, link, origin=None, name=None, prefix=''):
         link_names = set(myself.link_map.keys())
         other_link_names = set(other.link_map.keys())
         if len(link_names.intersection(other_link_names)) > 0:
-            raise ValueError('Cannot merge two URDFs with shared link names')
+            raise ValueError("Cannot merge two URDFs with shared link names")
 
         joint_names = set(myself.joint_map.keys())
         other_joint_names = set(other.joint_map.keys())
         if len(joint_names.intersection(other_joint_names)) > 0:
-            raise ValueError('Cannot merge two URDFs with shared joint names')
+            raise ValueError("Cannot merge two URDFs with shared joint names")
 
         links = myself.links + other.links
         joints = myself.joints + other.joints
@@ -3673,20 +3759,26 @@ def join(self, other, link, origin=None, name=None, prefix=''):
             name = self.name
 
         # Create joint that links the two rigidly
-        joints.append(Joint(
-            name='{}_join_{}{}_joint'.format(self.name, prefix, other.name),
-            joint_type='fixed',
-            parent=link if isinstance(link, str) else link.name,
-            child=other.base_link.name,
-            origin=origin
-        ))
+        joints.append(
+            Joint(
+                name="{}_join_{}{}_joint".format(self.name, prefix, other.name),
+                joint_type="fixed",
+                parent=link if isinstance(link, str) else link.name,
+                child=other.base_link.name,
+                origin=origin,
+            )
+        )
 
-        return URDF(name=name, links=links, joints=joints, transmissions=transmissions,
-                    materials=materials)
+        return URDF(
+            name=name,
+            links=links,
+            joints=joints,
+            transmissions=transmissions,
+            materials=materials,
+        )
 
     def _merge_materials(self):
-        """Merge the top-level material set with the link materials.
-        """
+        """Merge the top-level material set with the link materials."""
         for link in self.links:
             for v in link.visuals:
                 if v.material is None:
@@ -3698,7 +3790,7 @@ def _merge_materials(self):
                     self._material_map[v.material.name] = v.material
 
     @staticmethod
-    def load(file_obj):
+    def load(file_obj, lazy_load_meshes=False):
         """Load a URDF from a file.
 
         Parameters
@@ -3708,6 +3800,10 @@ def load(file_obj):
             ``.urdf`` XML file. Any paths in the URDF should be specified
             as relative paths to the ``.urdf`` file instead of as ROS
             resources.
+        lazy_load_meshes : bool
+            If true, meshes will only loaded when requested by a function call.
+            This dramatically speeds up loading time for the URDF but may lead
+            to unexpected timing mid-program when the meshes have to be loaded
 
         Returns
         -------
@@ -3716,19 +3812,18 @@ def load(file_obj):
         """
         if isinstance(file_obj, six.string_types):
             if os.path.isfile(file_obj):
-                parser = ET.XMLParser(remove_comments=True,
-                                      remove_blank_text=True)
+                parser = ET.XMLParser(remove_comments=True, remove_blank_text=True)
                 tree = ET.parse(file_obj, parser=parser)
                 path, _ = os.path.split(file_obj)
             else:
-                raise ValueError('{} is not a file'.format(file_obj))
+                raise ValueError("{} is not a file".format(file_obj))
         else:
             parser = ET.XMLParser(remove_comments=True, remove_blank_text=True)
             tree = ET.parse(file_obj, parser=parser)
             path, _ = os.path.split(file_obj.name)
 
         node = tree.getroot()
-        return URDF._from_xml(node, path)
+        return URDF._from_xml(node, path, lazy_load_meshes)
 
     def _validate_joints(self):
         """Raise an exception of any joints are invalidly specified.
@@ -3747,26 +3842,33 @@ def _validate_joints(self):
         actuated_joints = []
         for joint in self.joints:
             if joint.parent not in self._link_map:
-                raise ValueError('Joint {} has invalid parent link name {}'
-                                 .format(joint.name, joint.parent))
+                raise ValueError(
+                    "Joint {} has invalid parent link name {}".format(
+                        joint.name, joint.parent
+                    )
+                )
             if joint.child not in self._link_map:
-                raise ValueError('Joint {} has invalid child link name {}'
-                                 .format(joint.name, joint.child))
+                raise ValueError(
+                    "Joint {} has invalid child link name {}".format(
+                        joint.name, joint.child
+                    )
+                )
             if joint.child == joint.parent:
-                raise ValueError('Joint {} has matching parent and child'
-                                 .format(joint.name))
+                raise ValueError(
+                    "Joint {} has matching parent and child".format(joint.name)
+                )
             if joint.mimic is not None:
                 if joint.mimic.joint not in self._joint_map:
                     raise ValueError(
-                        'Joint {} has an invalid mimic joint name {}'
-                        .format(joint.name, joint.mimic.joint)
+                        "Joint {} has an invalid mimic joint name {}".format(
+                            joint.name, joint.mimic.joint
+                        )
                     )
                 if joint.mimic.joint == joint.name:
                     raise ValueError(
-                        'Joint {} set up to mimic itself'
-                        .format(joint.mimic.joint)
+                        "Joint {} set up to mimic itself".format(joint.mimic.joint)
                     )
-            elif joint.joint_type != 'fixed':
+            elif joint.joint_type != "fixed":
                 actuated_joints.append(joint)
 
         # Do a depth-first search
@@ -3802,8 +3904,10 @@ def _validate_transmissions(self):
         for t in self.transmissions:
             for joint in t.joints:
                 if joint.name not in self._joint_map:
-                    raise ValueError('Transmission {} has invalid joint name '
-                                     '{}'.format(t.name, joint.name))
+                    raise ValueError(
+                        "Transmission {} has invalid joint name "
+                        "{}".format(t.name, joint.name)
+                    )
 
     def _validate_graph(self):
         """Raise an exception if the link-joint structure is invalid.
@@ -3830,17 +3934,16 @@ def _validate_graph(self):
                 for n in cc:
                     cluster.append(n.name)
                 link_clusters.append(cluster)
-            message = ('Links are not all connected. '
-                       'Connected components are:')
+            message = "Links are not all connected. " "Connected components are:"
             for lc in link_clusters:
-                message += '\n\t'
+                message += "\n\t"
                 for n in lc:
-                    message += ' {}'.format(n)
+                    message += " {}".format(n)
             raise ValueError(message)
 
         # Check that link graph is acyclic
         if not nx.is_directed_acyclic_graph(self._G):
-            raise ValueError('There are cycles in the link graph')
+            raise ValueError("There are cycles in the link graph")
 
         # Ensure that there is exactly one base link, which has no parent
         base_link = None
@@ -3850,8 +3953,11 @@ def _validate_graph(self):
                 if base_link is None:
                     base_link = n
                 else:
-                    raise ValueError('Links {} and {} are both base links!'
-                                     .format(n.name, base_link.name))
+                    raise ValueError(
+                        "Links {} and {} are both base links!".format(
+                            n.name, base_link.name
+                        )
+                    )
             if len(nx.ancestors(self._G, n)) == 0:
                 end_links.append(n)
         return base_link, end_links
@@ -3871,12 +3977,14 @@ def _process_cfg(self, cfg):
                     joint_cfg[joint] = cfg[joint]
         elif isinstance(cfg, (list, tuple, np.ndarray)):
             if len(cfg) != len(self.actuated_joints):
-                raise ValueError('Cfg must have same length as actuated joints '
-                                 'if specified as a numerical array')
+                raise ValueError(
+                    "Cfg must have same length as actuated joints "
+                    "if specified as a numerical array"
+                )
             for joint, value in zip(self.actuated_joints, cfg):
                 joint_cfg[joint] = value
         else:
-            raise TypeError('Invalid type for config')
+            raise TypeError("Invalid type for config")
         return joint_cfg
 
     def _process_cfgs(self, cfgs):
@@ -3886,7 +3994,7 @@ def _process_cfgs(self, cfgs):
         This should result in a dict mapping each joint to a list of cfg values, one
         per joint.
         """
-        joint_cfg = {j : [] for j in self.actuated_joints}
+        joint_cfg = {j: [] for j in self.actuated_joints}
         n_cfgs = None
         if isinstance(cfgs, dict):
             for joint in cfgs:
@@ -3910,30 +4018,30 @@ def _process_cfgs(self, cfgs):
             else:
                 cfgs = np.asanyarray(cfgs, dtype=np.float64)
                 for i, j in enumerate(self.actuated_joints):
-                    joint_cfg[j] = cfgs[:,i]
+                    joint_cfg[j] = cfgs[:, i]
         else:
-            raise ValueError('Incorrectly formatted config array')
+            raise ValueError("Incorrectly formatted config array")
 
         for j in joint_cfg:
             if len(joint_cfg[j]) == 0:
                 joint_cfg[j] = None
             elif len(joint_cfg[j]) != n_cfgs:
-                raise ValueError('Inconsistent number of configurations for joints')
+                raise ValueError("Inconsistent number of configurations for joints")
 
         return joint_cfg, n_cfgs
 
     @classmethod
-    def _from_xml(cls, node, path):
-        valid_tags = set(['joint', 'link', 'transmission', 'material'])
-        kwargs = cls._parse(node, path)
+    def _from_xml(cls, node, path, lazy_load_meshes):
+        valid_tags = set(["joint", "link", "transmission", "material"])
+        kwargs = cls._parse(node, path, lazy_load_meshes)
 
-        extra_xml_node = ET.Element('extra')
+        extra_xml_node = ET.Element("extra")
         for child in node:
             if child.tag not in valid_tags:
                 extra_xml_node.append(child)
 
         data = ET.tostring(extra_xml_node)
-        kwargs['other_xml'] = data
+        kwargs["other_xml"] = data
         return URDF(**kwargs)
 
     def _to_xml(self, parent, path):
diff --git a/urdfpy/utils.py b/urchin/utils.py
similarity index 100%
rename from urdfpy/utils.py
rename to urchin/utils.py
diff --git a/urchin/version.py b/urchin/version.py
new file mode 100644
index 0000000..573b11c
--- /dev/null
+++ b/urchin/version.py
@@ -0,0 +1,1 @@
+__version__ = "0.0.27"
diff --git a/urdfpy/version.py b/urdfpy/version.py
deleted file mode 100644
index 23a88d0..0000000
--- a/urdfpy/version.py
+++ /dev/null
@@ -1,1 +0,0 @@
-__version__ = '0.0.22'


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..ea7e337
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,34 @@
+name: Test
+
+on:
+  push:
+  pull_request:
+  schedule:
+  # * is a special character in YAML so you have to quote this string
+  # Execute a "weekly" build at 0 AM UTC on Mondays
+  - cron:  '0 0 * * MON'
+
+jobs:
+  build:
+
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version: ["3.8", "3.9", "3.10", "3.11"]
+
+    steps:
+    - uses: actions/checkout@v3
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v3
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install package
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install flake8 pytest pytest-cov coveralls wheel
+        python -m pip install .
+
+    - name: Test with pytest
+      run: |
+        pytest --cov=urchin tests
diff --git a/tests/unit/test_urdf.py b/tests/unit/test_urdf.py
index 9690bd6..0af5644 100644
--- a/tests/unit/test_urdf.py
+++ b/tests/unit/test_urdf.py
@@ -2,14 +2,14 @@
 import pytest
 import trimesh
 
-from urdfpy import URDF, Link, Joint, Transmission, Material
+from urchin import URDF, Link, Joint, Transmission, Material
 
 
-def test_urdfpy(tmpdir):
-    outfn = tmpdir.mkdir('urdf').join('ur5.urdf').strpath
+def test_urchin(tmpdir):
+    outfn = tmpdir.mkdir("urdf").join("ur5.urdf").strpath
 
     # Load
-    u = URDF.load('tests/data/ur5/ur5.urdf')
+    u = URDF.load("tests/data/ur5/ur5.urdf")
 
     assert isinstance(u, URDF)
     for j in u.joints:
@@ -27,33 +27,33 @@ def test_urdfpy(tmpdir):
     for l in fk:
         assert isinstance(l, Link)
         assert isinstance(fk[l], np.ndarray)
-        assert fk[l].shape == (4,4)
+        assert fk[l].shape == (4, 4)
 
-    fk = u.link_fk({'shoulder_pan_joint': 2.0})
+    fk = u.link_fk({"shoulder_pan_joint": 2.0})
     assert isinstance(fk, dict)
     for l in fk:
         assert isinstance(l, Link)
         assert isinstance(fk[l], np.ndarray)
-        assert fk[l].shape == (4,4)
+        assert fk[l].shape == (4, 4)
 
     fk = u.link_fk(np.zeros(6))
     assert isinstance(fk, dict)
     for l in fk:
         assert isinstance(l, Link)
         assert isinstance(fk[l], np.ndarray)
-        assert fk[l].shape == (4,4)
+        assert fk[l].shape == (4, 4)
 
-    fk = u.link_fk(np.zeros(6), link='upper_arm_link')
+    fk = u.link_fk(np.zeros(6), link="upper_arm_link")
     assert isinstance(fk, np.ndarray)
-    assert fk.shape == (4,4)
+    assert fk.shape == (4, 4)
 
-    fk = u.link_fk(links=['shoulder_link', 'upper_arm_link'])
+    fk = u.link_fk(links=["shoulder_link", "upper_arm_link"])
     assert isinstance(fk, dict)
     assert len(fk) == 2
     for l in fk:
         assert isinstance(l, Link)
         assert isinstance(fk[l], np.ndarray)
-        assert fk[l].shape == (4,4)
+        assert fk[l].shape == (4, 4)
 
     fk = u.link_fk(links=list(u.links)[:2])
     assert isinstance(fk, dict)
@@ -61,32 +61,32 @@ def test_urdfpy(tmpdir):
     for l in fk:
         assert isinstance(l, Link)
         assert isinstance(fk[l], np.ndarray)
-        assert fk[l].shape == (4,4)
+        assert fk[l].shape == (4, 4)
 
-    cfg={j.name: 0.5 for j in u.actuated_joints}
+    cfg = {j.name: 0.5 for j in u.actuated_joints}
     for _ in range(1000):
         fk = u.collision_trimesh_fk(cfg=cfg)
         for key in fk:
             assert isinstance(fk[key], np.ndarray)
-            assert fk[key].shape == (4,4)
+            assert fk[key].shape == (4, 4)
 
     cfg = {j.name: np.random.uniform(size=1000) for j in u.actuated_joints}
     fk = u.link_fk_batch(cfgs=cfg)
     for key in fk:
         assert isinstance(fk[key], np.ndarray)
-        assert fk[key].shape == (1000,4,4)
+        assert fk[key].shape == (1000, 4, 4)
 
-    cfg={j.name: 0.5 for j in u.actuated_joints}
+    cfg = {j.name: 0.5 for j in u.actuated_joints}
     for _ in range(1000):
         fk = u.collision_trimesh_fk(cfg=cfg)
         for key in fk:
             assert isinstance(key, trimesh.Trimesh)
-            assert fk[key].shape == (4,4)
+            assert fk[key].shape == (4, 4)
     cfg = {j.name: np.random.uniform(size=1000) for j in u.actuated_joints}
     fk = u.collision_trimesh_fk_batch(cfgs=cfg)
     for key in fk:
         assert isinstance(key, trimesh.Trimesh)
-        assert fk[key].shape == (1000,4,4)
+        assert fk[key].shape == (1000, 4, 4)
 
     # Test save
     u.save(outfn)
@@ -97,15 +97,15 @@ def test_urdfpy(tmpdir):
 
     # Test join
     with pytest.raises(ValueError):
-        x = u.join(u, link=u.link_map['tool0'])
-    x = u.join(u, link=u.link_map['tool0'], name='copy', prefix='prefix')
+        x = u.join(u, link=u.link_map["tool0"])
+    x = u.join(u, link=u.link_map["tool0"], name="copy", prefix="prefix")
     assert isinstance(x, URDF)
-    assert x.name == 'copy'
+    assert x.name == "copy"
     assert len(x.joints) == 2 * len(u.joints) + 1
     assert len(x.links) == 2 * len(u.links)
 
     # Test scale
     x = u.copy(scale=3)
     assert isinstance(x, URDF)
-    x = x.copy(scale=[1,1,3])
+    x = x.copy(scale=[1, 1, 3])
     assert isinstance(x, URDF)
diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py
index 1baaac7..01bff1a 100644
--- a/tests/unit/test_utils.py
+++ b/tests/unit/test_utils.py
@@ -1,14 +1,13 @@
 import numpy as np
 import trimesh.transformations as tfs
 
-from urdfpy import (rpy_to_matrix, matrix_to_rpy, matrix_to_xyz_rpy,
-                    xyz_rpy_to_matrix)
+from urchin import rpy_to_matrix, matrix_to_rpy, matrix_to_xyz_rpy, xyz_rpy_to_matrix
 
 
 def test_rpy_to_matrix():
-    xr45 = tfs.rotation_matrix(np.pi / 2, [1, 0, 0])[:3,:3]
-    yr45 = tfs.rotation_matrix(np.pi / 2, [0, 1, 0])[:3,:3]
-    zr45 = tfs.rotation_matrix(np.pi / 2, [0, 0, 1])[:3,:3]
+    xr45 = tfs.rotation_matrix(np.pi / 2, [1, 0, 0])[:3, :3]
+    yr45 = tfs.rotation_matrix(np.pi / 2, [0, 1, 0])[:3, :3]
+    zr45 = tfs.rotation_matrix(np.pi / 2, [0, 0, 1])[:3, :3]
     c = zr45.dot(yr45.dot(xr45))
 
     assert np.allclose(rpy_to_matrix([np.pi / 2, 0, 0]), xr45)
@@ -18,9 +17,9 @@ def test_rpy_to_matrix():
 
 
 def test_matrix_to_rpy():
-    xr45 = tfs.rotation_matrix(np.pi / 2, [1, 0, 0])[:3,:3]
-    yr45 = tfs.rotation_matrix(np.pi / 2, [0, 1, 0])[:3,:3]
-    zr45 = tfs.rotation_matrix(np.pi / 2, [0, 0, 1])[:3,:3]
+    xr45 = tfs.rotation_matrix(np.pi / 2, [1, 0, 0])[:3, :3]
+    yr45 = tfs.rotation_matrix(np.pi / 2, [0, 1, 0])[:3, :3]
+    zr45 = tfs.rotation_matrix(np.pi / 2, [0, 0, 1])[:3, :3]
 
     assert np.allclose([np.pi / 2, 0, 0], matrix_to_rpy(xr45))
     assert np.allclose([0, np.pi / 2, 0], matrix_to_rpy(yr45))
@@ -32,9 +31,9 @@ def test_matrix_to_xyz_rpy():
     yr45 = tfs.rotation_matrix(np.pi / 2, [0, 1, 0])
     zr45 = tfs.rotation_matrix(np.pi / 2, [0, 0, 1])
 
-    xr45[:3,3] = np.array([1,2,3])
-    yr45[:3,3] = np.array([2,3,1])
-    zr45[:3,3] = np.array([3,1,2])
+    xr45[:3, 3] = np.array([1, 2, 3])
+    yr45[:3, 3] = np.array([2, 3, 1])
+    zr45[:3, 3] = np.array([3, 1, 2])
 
     assert np.allclose([1, 2, 3, np.pi / 2, 0, 0], matrix_to_xyz_rpy(xr45))
     assert np.allclose([2, 3, 1, 0, np.pi / 2, 0], matrix_to_xyz_rpy(yr45))
@@ -46,9 +45,9 @@ def test_xyz_rpy_to_matrix():
     yr45 = tfs.rotation_matrix(np.pi / 2, [0, 1, 0])
     zr45 = tfs.rotation_matrix(np.pi / 2, [0, 0, 1])
 
-    xr45[:3,3] = np.array([1,2,3])
-    yr45[:3,3] = np.array([2,3,1])
-    zr45[:3,3] = np.array([3,1,2])
+    xr45[:3, 3] = np.array([1, 2, 3])
+    yr45[:3, 3] = np.array([2, 3, 1])
+    zr45[:3, 3] = np.array([3, 1, 2])
     assert np.allclose(xyz_rpy_to_matrix([1, 2, 3, np.pi / 2, 0, 0]), xr45)
     assert np.allclose(xyz_rpy_to_matrix([2, 3, 1, 0, np.pi / 2, 0]), yr45)
     assert np.allclose(xyz_rpy_to_matrix([3, 1, 2, 0, 0, np.pi / 2]), zr45)