├── .github └── workflows │ └── wheels.yml ├── .gitignore ├── .readthedocs.yaml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README-pypi.md ├── README.md ├── docs ├── .gitignore ├── Makefile ├── make.bat ├── requirements.txt ├── source │ ├── _static │ │ ├── architecture.svg │ │ ├── css │ │ │ └── custom.css │ │ ├── dof_tree.svg │ │ ├── fixed.svg │ │ ├── frame.svg │ │ ├── img │ │ │ ├── after.png │ │ │ ├── architecture.png │ │ │ ├── before.png │ │ │ ├── configuration.png │ │ │ ├── design.png │ │ │ ├── design.xcf │ │ │ ├── fixed.png │ │ │ ├── frame.png │ │ │ ├── frames.png │ │ │ ├── gear.png │ │ │ ├── loop.png │ │ │ ├── main.png │ │ │ ├── opened_chain.png │ │ │ ├── pure-shape.png │ │ │ ├── shape-approx.png │ │ │ ├── shape-approx.xcf │ │ │ ├── smalls │ │ │ │ ├── after.png │ │ │ │ ├── before.png │ │ │ │ ├── design.png │ │ │ │ ├── frame.png │ │ │ │ ├── main.png │ │ │ │ ├── pure-shape.png │ │ │ │ └── shape-approx.png │ │ │ └── zaxis.png │ │ ├── loop.svg │ │ └── main.svg │ ├── cache.rst │ ├── conf.py │ ├── config.rst │ ├── custom_processors.rst │ ├── design.rst │ ├── exporter_mujoco.rst │ ├── exporter_sdf.rst │ ├── exporter_urdf.rst │ ├── getting_started.rst │ ├── index.rst │ ├── kinematic_loops.rst │ ├── processor_ball_to_euler.rst │ ├── processor_collision_as_visual.rst │ ├── processor_convex_decomposition.rst │ ├── processor_dummy_base_link.rst │ ├── processor_fixed_links.rst │ ├── processor_merge_parts.rst │ ├── processor_no_collision_meshes.rst │ ├── processor_scad.rst │ ├── processor_simplify_stls.rst │ └── processors.rst └── watch.sh ├── onshape_to_robot ├── __init__.py ├── assembly.py ├── assets │ └── scene.xml ├── bullet.py ├── bullet │ ├── plane.obj │ └── plane.urdf ├── clear_cache.py ├── config.py ├── csg.py ├── edit_shape.py ├── export.py ├── exporter.py ├── exporter_mujoco.py ├── exporter_sdf.py ├── exporter_urdf.py ├── exporter_utils.py ├── expression.py ├── geometry.py ├── message.py ├── mujoco.py ├── onshape_api │ ├── __init__.py │ ├── cache.py │ ├── client.py │ ├── onshape.py │ └── utils.py ├── processor.py ├── processor_ball_to_euler.py ├── processor_collision_as_visual.py ├── processor_convex_decomposition.py ├── processor_dummy_base_link.py ├── processor_fixed_links.py ├── processor_merge_parts.py ├── processor_no_collision_meshes.py ├── processor_scad.py ├── processor_simplify_stls.py ├── processors.py ├── pure_sketch.py ├── robot.py ├── robot_builder.py └── simulation.py ├── requirements.txt └── setup.py /.github/workflows/wheels.yml: -------------------------------------------------------------------------------- 1 | name: Build and upload to PyPI 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | build_sdist: 10 | name: Build source distribution 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Build sdist 16 | run: pipx run build --sdist 17 | 18 | - uses: actions/upload-artifact@v4 19 | with: 20 | name: cibw-sdist 21 | path: dist/*.tar.gz 22 | 23 | upload_pypi: 24 | needs: [build_sdist] 25 | runs-on: ubuntu-latest 26 | environment: pypi 27 | permissions: 28 | id-token: write 29 | if: github.event_name == 'release' && github.event.action == 'published' 30 | steps: 31 | - uses: actions/download-artifact@v4 32 | with: 33 | # unpacks all CIBW artifacts into dist/ 34 | pattern: cibw-* 35 | path: dist 36 | merge-multiple: true 37 | 38 | - uses: pypa/gh-action-pypi-publish@release/v1 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **.pyc 2 | .vscode 3 | robots 4 | data 5 | **/cache/ 6 | dist 7 | build 8 | onshape_to_robot.egg-info 9 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | sphinx: 16 | configuration: docs/source/conf.py 17 | 18 | # We recommend specifying your dependencies to enable reproducible builds: 19 | # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 20 | python: 21 | install: 22 | - requirements: docs/requirements.txt 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019-2099 Rhoban Team 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README-pypi.md 2 | 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | all: 3 | @rm -rf dist/* 4 | python3 setup.py sdist bdist_wheel 5 | 6 | upload: 7 | python3 -m twine upload --repository pypi dist/* 8 | 9 | upload-test: 10 | python3 -m twine upload --repository testpypi dist/* 11 | 12 | clean: 13 | rm -rf build dist onshape_to_robot.egg-info 14 | -------------------------------------------------------------------------------- /README-pypi.md: -------------------------------------------------------------------------------- 1 | # Onshape to robot (URDF, SDF, MuJoCo) 2 | 3 | This tool is based on the [Onshape API](https://dev-portal.onshape.com/) to retrieve 4 | informations from an assembly and build a robot description (URDF, SDF, MuJoCo) suitable for physics 5 | simulation. 6 | 7 | * Check out the [official documentation](https://onshape-to-robot.readthedocs.io/) 8 | * [GitHub repository](https://github.com/rhoban/onshape-to-robot/) 9 | * [Robots examples](https://github.com/rhoban/onshape-to-robot-examples) 10 | 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Onshape to Robot (URDF, SDF, MuJoCo) 2 | 3 |

4 | 5 |

6 | 7 | This tool is based on the [Onshape API](https://dev-portal.onshape.com/) to retrieve 8 | informations from an assembly and build a robot description (URDF, SDF, MuJoCo ) suitable 9 | for physics simulation. 10 | 11 | * [Documentation](https://onshape-to-robot.readthedocs.io/) 12 | * [Examples](https://github.com/rhoban/onshape-to-robot-examples) 13 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx-rtd-theme 2 | -------------------------------------------------------------------------------- /docs/source/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | 2 | img.padding { 3 | margin-bottom: 20px !important; 4 | } -------------------------------------------------------------------------------- /docs/source/_static/img/after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/after.png -------------------------------------------------------------------------------- /docs/source/_static/img/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/architecture.png -------------------------------------------------------------------------------- /docs/source/_static/img/before.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/before.png -------------------------------------------------------------------------------- /docs/source/_static/img/configuration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/configuration.png -------------------------------------------------------------------------------- /docs/source/_static/img/design.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/design.png -------------------------------------------------------------------------------- /docs/source/_static/img/design.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/design.xcf -------------------------------------------------------------------------------- /docs/source/_static/img/fixed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/fixed.png -------------------------------------------------------------------------------- /docs/source/_static/img/frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/frame.png -------------------------------------------------------------------------------- /docs/source/_static/img/frames.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/frames.png -------------------------------------------------------------------------------- /docs/source/_static/img/gear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/gear.png -------------------------------------------------------------------------------- /docs/source/_static/img/loop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/loop.png -------------------------------------------------------------------------------- /docs/source/_static/img/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/main.png -------------------------------------------------------------------------------- /docs/source/_static/img/opened_chain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/opened_chain.png -------------------------------------------------------------------------------- /docs/source/_static/img/pure-shape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/pure-shape.png -------------------------------------------------------------------------------- /docs/source/_static/img/shape-approx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/shape-approx.png -------------------------------------------------------------------------------- /docs/source/_static/img/shape-approx.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/shape-approx.xcf -------------------------------------------------------------------------------- /docs/source/_static/img/smalls/after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/smalls/after.png -------------------------------------------------------------------------------- /docs/source/_static/img/smalls/before.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/smalls/before.png -------------------------------------------------------------------------------- /docs/source/_static/img/smalls/design.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/smalls/design.png -------------------------------------------------------------------------------- /docs/source/_static/img/smalls/frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/smalls/frame.png -------------------------------------------------------------------------------- /docs/source/_static/img/smalls/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/smalls/main.png -------------------------------------------------------------------------------- /docs/source/_static/img/smalls/pure-shape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/smalls/pure-shape.png -------------------------------------------------------------------------------- /docs/source/_static/img/smalls/shape-approx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/smalls/shape-approx.png -------------------------------------------------------------------------------- /docs/source/_static/img/zaxis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhoban/onshape-to-robot/ffd0000a6882aa4163e4e16d5b7f9d8e4f21a7d9/docs/source/_static/img/zaxis.png -------------------------------------------------------------------------------- /docs/source/cache.rst: -------------------------------------------------------------------------------- 1 | Cache 2 | ===== 3 | 4 | Presentation 5 | ------------ 6 | 7 | In order to re-issue the same requests to the Onshape API, ``onshape-to-robot`` caches the result of most of the requests. 8 | 9 | .. note:: 10 | 11 | All requests involving a **workspace** can't be cached, since they rely on live version that are subject to change. 12 | 13 | Clearing the cache 14 | ------------------ 15 | 16 | You can clear the cache using the following command: 17 | 18 | .. code-block:: bash 19 | 20 | onshape-to-robot-clear-cache -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'Onshape to robot' 21 | copyright = '2025, Rhoban' 22 | author = 'Rhoban' 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = 'latest' 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | ] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ['_templates'] 38 | 39 | # List of patterns, relative to source directory, that match files and 40 | # directories to ignore when looking for source files. 41 | # This pattern also affects html_static_path and html_extra_path. 42 | exclude_patterns = [] 43 | 44 | 45 | # -- Options for HTML output ------------------------------------------------- 46 | 47 | # The theme to use for HTML and HTML Help pages. See the documentation for 48 | # a list of builtin themes. 49 | # 50 | html_theme = "sphinx_rtd_theme" 51 | 52 | # Add any paths that contain custom static files (such as style sheets) here, 53 | # relative to this directory. They are copied after the builtin static files, 54 | # so a file named "default.css" will overwrite the builtin "default.css". 55 | html_static_path = ['_static'] 56 | 57 | html_css_files = [ 58 | 'css/custom.css', 59 | ] 60 | 61 | master_doc = 'index' -------------------------------------------------------------------------------- /docs/source/config.rst: -------------------------------------------------------------------------------- 1 | Configuration (config.json) 2 | =========================== 3 | 4 | Specific entries 5 | ---------------- 6 | 7 | Below are the global configuration entries. 8 | You might also want to check out the following documentation for more specific entries: 9 | 10 | * Exporters 11 | * :doc:`URDF specific entries ` 12 | * :doc:`SDF specific entries ` 13 | * :doc:`MuJoCo specific entries ` 14 | * :doc:`Processors ` can define their own specific entries 15 | 16 | 17 | ``config.json`` entries 18 | ----------------------- 19 | 20 | Here is an example of complete ``config.json`` file, with details below: 21 | 22 | .. code-block:: javascript 23 | 24 | // config.json general options 25 | // for urdf or mujoco specific options, see documentation 26 | { 27 | // Onshape assembly URL 28 | "url": "https://cad.onshape.com/documents/11a7f59e37f711d732274fca/w/7807518dc67487ad405722c8/e/5233c6445c575366a6cc0d50", 29 | // Output format: urdf or mujoco (required) 30 | "output_format": "urdf", 31 | // Output filename (default: "robot") 32 | // Extension (.urdf, .xml) will be added automatically 33 | "output_filename": "robot", 34 | // Assets directory (default: "assets") 35 | "assets_dir": "assets", 36 | 37 | // If you don't use "url", you can alternatively specify the following 38 | // The Onshape document id to parse, see "getting started" (optional) 39 | "document_id": "document-id", 40 | // The document version id (optional) 41 | "version_id": "version-id", 42 | // The workspace id (optional) 43 | "workspace_id": "workspace-id", 44 | // Element id (optional) 45 | "element_id": "element-id", 46 | // Assembly name to use in the document (optional) 47 | "assembly_name": "robot", 48 | 49 | // Onshape configuration to use (default: "default") 50 | "configuration": "Configuration=BigFoot;RodLength=50mm", 51 | // Robot name (default: "onshape") 52 | "robot_name": "robot", 53 | 54 | // Ignore limits (default: false) 55 | "ignore_limits": true, 56 | 57 | // Parts to ignore (default: {}) 58 | "ignore": { 59 | // Ignore visual for visual 60 | "part1": "visual", 61 | "screw*": "visual", 62 | 63 | // Ignore everything expect "leg" for collision 64 | "*" : "collision" 65 | "!leg": "collision" 66 | }, 67 | 68 | // Whether to keep frame links (default: false) 69 | "draw_frames": true, 70 | // Override the color of all links (default: None) 71 | "color": [0.5, 0.1, 0.1], 72 | 73 | // Disable dynamics retrieval (default: false) 74 | "no_dynamics": true, 75 | 76 | // Whether to include configuration suffix to part (stl) files (default: true) 77 | "include_configuration_suffix": false, 78 | 79 | // Post import commands (default: []) 80 | "post_import_commands" [ 81 | "echo 'Import done'", 82 | "echo 'Do something else'" 83 | ], 84 | 85 | // Custom processors 86 | "processors": [ 87 | "my_project.my_custom_processor:MyCustomProcessor" 88 | ] 89 | 90 | // More options available in specific exporters (URDF, SDF, MuJoCo) 91 | // More options available in processors 92 | } 93 | 94 | .. note:: 95 | 96 | Comments are supported in the ``config.json`` file. 97 | 98 | .. note:: 99 | 100 | Since ``1.0.0``, all configuration entries are now snake case. For backward compatibility reasons, the old 101 | camel case entries are still supported. (for example, ``document_id`` and ``documentId`` are equivalent). 102 | 103 | ``url`` *(required)* 104 | ~~~~~~~~~~~~~~~~~~~~ 105 | 106 | The Onshape URL of the assembly to be exported. Be sure you are on the correct tab when copying the URL. 107 | 108 | ``output_format`` *(required)* 109 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 110 | 111 | **required** 112 | 113 | This should be either ``urdf`` or ``mujoco`` to specify which output format is wanted for robot description 114 | created by the export. 115 | 116 | ``output_filename`` *(default: robot)* 117 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 118 | 119 | This is the name of the output file without extension. By default "robot" (for example: ``robot.urdf``, ``robot.sdf`` or ``robot.xml``). 120 | 121 | ``assets_dir`` *(default: "assets")* 122 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 123 | 124 | This is the directory where the assets (like meshes) will be stored. 125 | 126 | ``assembly_name`` *(optional)* 127 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 128 | 129 | This can be used to specify the name of the assembly (in the Onshape document) to be used for robot export. 130 | 131 | If this is not provided, ``onshape-to-robot`` will list the assemblies. If more than one assembly is found, 132 | an error will be raised. 133 | 134 | ``document_id`` *(optional)* 135 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 136 | 137 | If you don't specify the URL, this is the onshape ID of the document to be imported. It can be found in the Onshape URL, 138 | just after ``document/``. 139 | 140 | .. code-block:: bash 141 | 142 | https://cad.onshape.com/documents/XXXXXXXXX/w/YYYYYYYY/e/ZZZZZZZZ 143 | ^^^^^^^^^ 144 | This is the document id 145 | 146 | ``version_id`` *(optional)* 147 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 148 | 149 | If you don't specify the URL, this argument can be used to use a specific version of the document instead of the last one. The version ID 150 | can be found in URL, after the ``/v/`` part when selecting a specific version in the tree. 151 | 152 | If it is not specified, the workspace will be retrieved and the live version will be used. 153 | 154 | ``workspace_id`` *(optional)* 155 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 156 | 157 | If you don't specify the URL, this argument can be used to use a specific workspace of the document. This can be used for specific branches 158 | ofr your robot without making a version. 159 | The workspace ID can be found in URL, after the ``/w/`` part when selecting a specific version in the tree. 160 | 161 | ``element_id`` *(optional)* 162 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 163 | 164 | If you don't specify the URL, this argument can be used to use a specific element of the document. 165 | The element ID can be found in URL, after the ``/e/`` part when selecting a specific version in the tree. 166 | 167 | ``configuration`` *(default: "default")* 168 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 169 | 170 | This is the robot configuration string that will be passed to Onshape. Lists, booleans and quantities are allowed. For example: 171 | 172 | .. image:: _static/img/configuration.png 173 | :width: 300px 174 | :align: center 175 | 176 | Should be written as the following: 177 | 178 | .. code-block:: text 179 | 180 | Configuration=Long;RemovePart=true;Length=30mm 181 | 182 | .. note:: 183 | 184 | Alternatively, you can specify the configuration as a dictionary: 185 | 186 | .. code-block:: json 187 | 188 | { 189 | // ... 190 | "configuration": { 191 | "Configuration": "Long", 192 | "RemovePart": true, 193 | "Length": "30mm" 194 | } 195 | } 196 | 197 | 198 | ``robot_name`` *(default: "dirname")* 199 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 200 | 201 | Specifies the robot name. This value is typically present in the header of the exported files. 202 | 203 | If it is not specified, the directory name will be used. 204 | 205 | ``ignore_limits`` *(default: false)* 206 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 207 | 208 | If set to ``true``, the joint limits coming from Onshape will be ignored during export. 209 | 210 | ``ignore`` *(default: {})* 211 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 212 | 213 | This can be a list of parts that you want to be ignored during the export. 214 | 215 | Alternatively, you can use a dict, where the values are either ``all``, ``visual`` or ``collision``. The rules will apply in order of appearance. 216 | 217 | You can use wildcards ``*`` to match multiple parts. 218 | 219 | You can prefix the part name with ``!`` to exclude it from the rule. For example, the following will ignore all parts for visual, except the ``leg`` part, turning the ignore list to a whitelist: 220 | 221 | .. code-block:: json 222 | 223 | { 224 | // Ignore everything from visual 225 | "*": "collision", 226 | // Except the leg part 227 | "!leg": "collision" 228 | } 229 | 230 | .. note:: 231 | 232 | The dynamics of the part will not be ignored, but the visual and collision aspect will. 233 | 234 | .. _draw-frames: 235 | 236 | ``draw_frames`` *(default: false)* 237 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 238 | 239 | When , the part that is used for positionning the frame is 240 | by default excluded from the output description (a dummy link is kept instead). Passing this option to ``true`` will 241 | keep it instead. 242 | 243 | ``no_dynamics`` *(default: false)* 244 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 245 | 246 | This flag can be set if there is no dynamics. In that case all masses and inertia will be set to 0. 247 | In pyBullet, this will result in static object (think of some environment for example). 248 | 249 | 250 | ``color`` *(default: None)* 251 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 252 | 253 | Can override the color for parts (should be an array: ``[r, g, b]`` with numbers from 0 to 1) 254 | 255 | ``include_configuration_suffix`` *(default: true)* 256 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 257 | 258 | When this flag is set to ``true`` (default), configurations will be added as a suffix to the part names and STL files. 259 | 260 | ``post_import_commands`` *(default: [])* 261 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 262 | 263 | This is an array of commands that will be executed after the import is done. It can be used to be sure that 264 | some processing scripts are run everytime you run onshape-to-robot. 265 | 266 | ``processors`` *(default: None)* 267 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 268 | 269 | See :ref:`custom processors ` for more information. -------------------------------------------------------------------------------- /docs/source/custom_processors.rst: -------------------------------------------------------------------------------- 1 | .. _custom_processors: 2 | 3 | Writing & registering custom Processor 4 | ====================================== 5 | 6 | Introduction 7 | ------------ 8 | 9 | In this documentation, you can find the description of all processors that are `registered by default `_. You can also write your own processor, by writing a class and registering it in the ``config.json`` file as described below. 10 | 11 | 12 | Minimal processor example 13 | ------------------------- 14 | 15 | Below is a minimal example of processor you can write: 16 | 17 | .. code-block:: python 18 | 19 | # my_project/my_custom_processor.py 20 | from onshape_to_robot.processor import Processor 21 | from onshape_to_robot.config import Config 22 | from onshape_to_robot.robot import Robot 23 | 24 | class MyCustomProcessor(Processor): 25 | def __init__(self, config: Config): 26 | super().__init__(config) 27 | 28 | self.use_my_custom: bool = config.get("use_my_custom", False) 29 | 30 | def process(self, robot: Robot): 31 | if self.use_my_custom: 32 | print(f"Custom processing for {robot.name} with custom processor.") 33 | 34 | Remember that processors processes the robot intermediate representation, that you can find in `robot.py `_. 35 | 36 | Registering your processor(s) 37 | ----------------------------- 38 | 39 | To register your processor, use the ``processors`` entry in ``config.json``: 40 | 41 | .. code-block:: javascript 42 | 43 | { 44 | "processors": [ 45 | // Custom processor 46 | "my_project.my_custom_processor:MyCustomProcessor", 47 | // Default processors 48 | "ProcessorScad", 49 | "ProcessorMergeParts", 50 | "ProcessorNoCollisionMeshes" 51 | ] 52 | } 53 | 54 | .. note:: 55 | 56 | The list of existing default processors can be found in `processors.py `_. -------------------------------------------------------------------------------- /docs/source/design.rst: -------------------------------------------------------------------------------- 1 | Design-time considerations 2 | ========================== 3 | 4 | Workflow overview 5 | ----------------- 6 | 7 | In order to make your robot possible to export, you need to follow some conventions. The summary is as follows: 8 | 9 | * ``onshape-to-robot`` exports an **assembly** of the robot, 10 | * Be sure this assembly is a **top-level assembly**, where instances are robot links (they can be parts or sub-assemblies), 11 | * The **first instance** in the assembly list will be considered as the base link, 12 | * All the instances in the assembly will become links in the export 13 | * **Mate connectors** should have special names (see below for details): 14 | 15 | * ``dof_name``: for degrees of freedom 16 | * ``frame_name``: to create a frame (site in MuJoCo) 17 | * ``fix_name``: fix two links together, causing ``onshape-to-robot`` to merge them 18 | * ``closing_name``: to close a kinematic loop (see :ref:`kinematic-loops`) 19 | * Other mates are not considered by ``onshape-to-robot`` 20 | 21 | * Orphaned links (that are not part of the kinematic chain) will be **fixed to the base link**, with a warning 22 | 23 | .. image:: _static/img/design.png 24 | :align: center 25 | 26 | Specifying degrees of freedom 27 | ----------------------------- 28 | 29 | To create a degree of freedom, you should use the ``dof_`` prefix when placing a mate connector. 30 | 31 | * If the mate connector is **cylindrical** or **revolute**, a ``revolute`` joint will be issued 32 | * If the mate connector is a **slider**, a ``prismatic`` joint will be issued 33 | * If the mate connector is **fastened**, a ``fixed`` joint will be issued 34 | 35 | .. note:: 36 | 37 | You can specify joint limits in Onshape, they will be understood and exported 38 | 39 | Inverting axis orientation 40 | -------------------------- 41 | 42 | You sometime might want your robot joint to rotate in the opposite direction than the one in the Onshape assembly. 43 | 44 | To that end, use the ``inv`` suffix in the mate connector name. For instance, ``dof_head_pitch_inv`` will result in a joint named ``head_pitch`` having the axis inverted with the one from the Onshape assembly. 45 | 46 | Naming links 47 | ------------ 48 | 49 | If you create a mate connector and name it ``link_something``, the link corresponding to the instance 50 | on which it is attached will be named ``something`` in the resulting export. 51 | 52 | .. _custom-frames: 53 | 54 | Adding custom frames in your model 55 | ---------------------------------- 56 | 57 | You can add your own custom frames (such as the end effector or the tip of a leg) to your model. 58 | 59 | * In URDF, it will produce a *dummy link* connected to the parent link with a fixed joint 60 | * In SDF, a ``frame`` element will be added 61 | * In MuJoCo, it will result in a *site* 62 | 63 | To do so, either: 64 | 65 | * Add a mate connector where you want your frame to be, and name it ``frame_something``, where ``something`` is the name of your frame 66 | 67 | **OR** 68 | 69 | * Add any relation between a body representing your frame and the body you want to attach it to. Name this relation ``frame_something``. 70 | 71 | .. image:: _static/img/frames.png 72 | :align: center 73 | :class: padding 74 | 75 | 76 | Here is a document that can be used (be sure to turn on "composite parts" when inserting it, use the ``frame`` composite part): `Onshape frame part `_ 77 | 78 | .. note:: 79 | 80 | The instance used for frame representation is only here for visualization purpose and is excluded from the robot. 81 | You can however include it by setting :ref:`draw_frames ` to ``true`` in the :doc:`config ` file, mostly for debugging purposes. 82 | 83 | Joint frames 84 | ------------ 85 | 86 | Joint frames are the ones you see in Onshape when you click on the joint in the tree on the left. 87 | Thus, they are always revolving around the z axis, or translating along the *z axis*. 88 | 89 | .. image:: _static/img/zaxis.png 90 | :align: center 91 | 92 | .. _fixed-robot: 93 | 94 | Fixed robot 95 | ----------- 96 | 97 | If you want to export a robot that is fixed to the ground, use the "Fixed" feture of Onshape: 98 | 99 | .. image:: _static/img/fixed.png 100 | :align: center 101 | :class: padding 102 | 103 | Robot with multiple base links 104 | ------------------------------ 105 | 106 | The robot can have multiple links. In that case, the first instance appearing on the list will be considered as a separate base link. 107 | 108 | .. note:: 109 | 110 | MuJoCo and SDF both supports multiple base links, while URDF doesn't. 111 | 112 | In that case, you might consider using multiple URDF files, or :ref:`adding a dummy base link`. However, this will fix all the base links to the base link without freedom. 113 | 114 | Gear relations 115 | -------------- 116 | 117 | Gear relations are exported by onshape-to-robot. Be sure to click the **source joint** first, and then the **target joint**. They will be exported as ```` in :doc:`URDF ` and :doc:`SDF ` formats, and as equality constraints in :doc:`MuJoCo `. 118 | 119 | .. image:: _static/img/gear.png 120 | :align: center 121 | -------------------------------------------------------------------------------- /docs/source/exporter_mujoco.rst: -------------------------------------------------------------------------------- 1 | .. _exporter-mujoco: 2 | 3 | MuJoCo 4 | ====== 5 | 6 | Introduction 7 | ------------- 8 | 9 | MuJoCo is a standard physics simulator, coming with an extensive description format. 10 | 11 | * Frames will be added as ``site`` tags in the MuJoCo XML file. 12 | * *Actuators* will be created for all actuated joints (see below). 13 | * When :ref:`kinematic loops ` are present, they will be enforced using equality constraints. 14 | 15 | * If the loop is achieved using a ``fixed`` connector, a ``weld`` constraint will be added. 16 | * If a ``ball`` joint is used, a ``connect`` constraint will be added 17 | * If a ``revolute`` joint is used, two ``connect`` constraints will be used 18 | 19 | * Additionally to the ``robot.xml`` file, a ``scene.xml`` file will be produced, adding floor and lighting useful for testing purpose. 20 | 21 | ``config.json`` entries (MuJoCo) 22 | -------------------------------- 23 | 24 | Here is an example of complete ``config.json`` file, with details below: 25 | 26 | .. code-block:: javascript 27 | 28 | { 29 | "url": "document-url", 30 | "output_format": "mujoco", 31 | // ... 32 | // General import options (see config.json documentation) 33 | // ... 34 | 35 | // Additional XML file to be included in the URDF (default: "") 36 | "additional_xml": "my_custom_file.xml", 37 | 38 | // Override joint properties (default: {}) 39 | "joint_properties": { 40 | // Default properties for all joints 41 | "*": { 42 | "actuated": true, 43 | "forcerange": 10.0, 44 | "frictionloss": 0.5, 45 | "limits": [0.5, 1.2] 46 | // ... 47 | }, 48 | // Set the properties for a specific joint 49 | "joint_name": { 50 | "forcerange": 20.0, 51 | "frictionloss": 0.1 52 | // ... 53 | } 54 | }, 55 | 56 | // Override equality attributes 57 | "equalities": { 58 | "closing_branch*": { 59 | "solref": "0.002 1", 60 | "solimp": "0.99 0.999 0.0005 0.5 2" 61 | } 62 | } 63 | } 64 | 65 | ``joint_properties`` *(default: {})* 66 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 67 | 68 | Allow to specify the properties of the joints produced in the URDF output. The key should be joint names. The special ``default`` key will set default values for each joints. 69 | 70 | Possible values are: 71 | 72 | * ``actuated``: *(default: true)* whether an actuator should be associated to this joint, 73 | * ``class``: a ``class="..."`` to be added to the joint (and actuator) 74 | * ``type`` *(default: position)* defines the actuator that will be produced 75 | * ``range`` *(default: true)*: if ``true``, the joint limits are reflected on the joint ``range`` attribute 76 | * ``limits``: Override the joint limits, should be a list of two values (min, max) 77 | 78 | * The following are reflected as ```` attributes: 79 | 80 | * ``frictionloss`` 81 | * ``damping`` 82 | * ``armature`` 83 | * ``stiffness`` 84 | 85 | * The following are reflected as actuator (```` or other) attributes: 86 | 87 | * ``kp``, ``kv`` and ``dampratio`` gains 88 | * ``forcerange`` 89 | 90 | ``equalities`` *(default: {})* 91 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 92 | 93 | This entry allows to override the equality attributes of the MuJoCo XML file. The key should be the equality name (which might contains wildcards ``*``), and the value should be a dictionary of attributes. 94 | 95 | This can be used to adjust the ``solref`` and ``solimp`` attributes of the equality constraints. 96 | 97 | ``additional_xml`` *(default: "")* 98 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 99 | 100 | If you want to include additional XML in the URDF, you can specify the path to the file here. This file will be included in the produced XML. 101 | 102 | .. note:: 103 | 104 | Alternatively, ``additional_xml`` can be a list of files -------------------------------------------------------------------------------- /docs/source/exporter_sdf.rst: -------------------------------------------------------------------------------- 1 | SDF 2 | === 3 | 4 | Introduction 5 | ------------- 6 | 7 | The `SDF format `_ is an extension of URDF extensively used in ROS. 8 | 9 | 10 | * When using SDF, frames will be exported as ```` items. 11 | * The links and joints always using ``relative_to`` attribute to specify the parent frame, keeping the same coordinates system as in URDF. 12 | * Additionally to the ``robot.sdf`` file, a ``model.config`` file will be produced, adding metadata useful for Gazebo. 13 | 14 | ``config.json`` entries (SDF) 15 | ----------------------------- 16 | 17 | Here is an example of complete ``config.json`` file, with details below: 18 | 19 | .. code-block:: javascript 20 | 21 | { 22 | "url": "document-url", 23 | "output_format": "sdf", 24 | // ... 25 | // General import options (see config.json documentation) 26 | // ... 27 | 28 | // Additional XML file to be included in the URDF (default: "") 29 | "additional_xml": "my_custom_file.xml", 30 | 31 | // Override joint properties (default: {}) 32 | "joint_properties": { 33 | // Default properties for all joints 34 | "*": { 35 | "max_effort": 10.0, 36 | "max_velocity": 6.0, 37 | "friction": 0.5 38 | }, 39 | // Set the properties for a specific joint 40 | "joint_name": { 41 | "max_effort": 20.0, 42 | "max_velocity": 10.0, 43 | "friction": 0.1, 44 | "limits": [0.5, 1.2] 45 | }, 46 | "wheel": { 47 | "type": "continuous" 48 | } 49 | }, 50 | } 51 | 52 | ``joint_properties`` *(default: {})* 53 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 54 | 55 | Allow to specify the properties of the joints produced in the URDF output. The key should be joint names. The special ``default`` key will set default values for each joints. 56 | 57 | Possible values are: 58 | 59 | * ``max_effort``: The maximum effort that can be applied to the joint (added in the ```` tag) 60 | * ``max_velocity``: The maximum velocity of the joint (added in the ```` tag) 61 | * ``friction``: The friction of the joint (added in the ```` tag) 62 | * ``type``: Sets the joint type (changing the ```` tag) 63 | * ``limits``: Override the joint limits, should be a list of two values (min, max) 64 | 65 | ``additional_xml`` *(default: "")* 66 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 67 | 68 | If you want to include additional XML in the URDF, you can specify the path to the file here. This file will be included in the produced SDF file. 69 | 70 | .. note:: 71 | 72 | Alternatively, ``additional_xml`` can be a list of files 73 | 74 | -------------------------------------------------------------------------------- /docs/source/exporter_urdf.rst: -------------------------------------------------------------------------------- 1 | URDF 2 | ==== 3 | 4 | Introduction 5 | ------------- 6 | 7 | URDF is a very standard format that can be exported by ``onshape-to-robot``. Below are the specific configuration entries that can be specified when using this format. 8 | 9 | When using this exporter, frames will be added as a *dummy links* attached to their body using a fixed joint 10 | 11 | ``config.json`` entries (URDF) 12 | ------------------------------ 13 | 14 | Here is an example of complete ``config.json`` file, with details below: 15 | 16 | .. code-block:: javascript 17 | 18 | { 19 | "url": "document-url", 20 | "output_format": "urdf", 21 | // ... 22 | // General import options (see config.json documentation) 23 | // ... 24 | 25 | // Package name (for ROS) (default: "") 26 | "package_name": "my_robot", 27 | // Additional XML file to be included in the URDF (default: "") 28 | "additional_xml": "my_custom_file.xml", 29 | // Exclude inertial data for fixed bodies (default: false) 30 | "set_zero_mass_to_fixed": true, 31 | 32 | // Override joint properties (default: {}) 33 | "joint_properties": { 34 | // Default properties for all joints 35 | "*": { 36 | "max_effort": 10.0, 37 | "max_velocity": 6.0, 38 | "friction": 0.5 39 | }, 40 | // Set the properties for a specific joint 41 | "joint_name": { 42 | "max_effort": 20.0, 43 | "max_velocity": 10.0, 44 | "friction": 0.1, 45 | "limits": [0.5, 1.2] 46 | }, 47 | "wheel": { 48 | "type": "continuous" 49 | } 50 | }, 51 | } 52 | 53 | ``joint_properties`` *(default: {})* 54 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 55 | 56 | Allow to specify the properties of the joints produced in the URDF output. The key should be joint names. The special ``default`` key will set default values for each joints. 57 | 58 | Possible values are: 59 | 60 | * ``max_effort``: The maximum effort that can be applied to the joint (added in the ```` tag) 61 | * ``max_velocity``: The maximum velocity of the joint (added in the ```` tag) 62 | * ``friction``: The friction of the joint (added in the ```` tag) 63 | * ``type``: Sets the joint type (changing the ```` tag) 64 | * ``limits``: Override the joint limits, should be a list of two values (min, max) 65 | 66 | ``package_name`` *(default: "")* 67 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 68 | 69 | If you are exporting a URDF for ROS, you can specify the package name here. This will be used in the ```` tag. 70 | 71 | ``additional_xml`` *(default: "")* 72 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 73 | 74 | If you want to include additional XML in the URDF, you can specify the path to the file here. This file will be included in the produced URDF file. 75 | 76 | .. note:: 77 | 78 | Alternatively, ``additional_xml`` can be a list of files 79 | 80 | ``set_zero_mass_to_fixed`` *(default: false)* 81 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 82 | 83 | This option sets the mass to 0 for bodies that are :ref:`fixed ` to the world. 84 | 85 | .. note:: 86 | 87 | In PyBullet, such bodies are indeed recognized as fixed -------------------------------------------------------------------------------- /docs/source/getting_started.rst: -------------------------------------------------------------------------------- 1 | Getting started 2 | =============== 3 | 4 | Installing the package 5 | ---------------------- 6 | 7 | Run the following to install onshape-to-robot from `pypi `_: 8 | 9 | .. code-block:: bash 10 | 11 | pip install onshape-to-robot 12 | 13 | .. _api-key: 14 | 15 | Setting up your API key 16 | ----------------------- 17 | 18 | You will need to obtain API key and secret from the 19 | `Onshape developer portal `_ 20 | 21 | API key must be set as environment variables. 22 | 23 | Using `.bashrc` 24 | ~~~~~~~~~~~~~~~ 25 | 26 | You can add something like this in your ``.bashrc``: 27 | 28 | .. code-block:: bash 29 | 30 | # .bashrc 31 | # Obtained at https://dev-portal.onshape.com/keys 32 | export ONSHAPE_API=https://cad.onshape.com 33 | export ONSHAPE_ACCESS_KEY=Your_Access_Key 34 | export ONSHAPE_SECRET_KEY=Your_Secret_Key 35 | 36 | Using `.env` file 37 | ~~~~~~~~~~~~~~~~~ 38 | 39 | Alternatively, you can also create a ``.env`` file in the root of your project: 40 | 41 | .. code-block:: bash 42 | 43 | # .env 44 | # Obtained at https://dev-portal.onshape.com/keys 45 | ONSHAPE_API=https://cad.onshape.com 46 | ONSHAPE_ACCESS_KEY=Your_Access_Key 47 | ONSHAPE_SECRET_KEY=Your_Secret_Key 48 | 49 | Setting up your export 50 | ---------------------- 51 | 52 | To export your own robot, first create a directory: 53 | 54 | .. code-block:: bash 55 | 56 | mkdir my-robot 57 | 58 | Then edit ``my-robot/config.json``, here is the minimum example: 59 | 60 | .. code-block:: json 61 | 62 | { 63 | // Onshape URL of the assembly 64 | "url": "https://cad.onshape.com/documents/11a7f59e37f711d732274fca/w/7807518dc67487ad405722c8/e/5233c6445c575366a6cc0d50", 65 | // Output format 66 | "output_format": "urdf" 67 | } 68 | 69 | .. note:: 70 | 71 | The Onshape URL should be the one of your assembly. Be sure to be on the right tab when you copy it. 72 | 73 | Once this is done, run the following command: 74 | 75 | .. code-block:: bash 76 | 77 | onshape-to-robot my-robot 78 | 79 | 80 | Testing your export 81 | ------------------- 82 | 83 | You can test your export by running (PyBullet): 84 | 85 | .. code-block:: bash 86 | 87 | onshape-to-robot-bullet my-robot 88 | 89 | Or (MuJoCo): 90 | 91 | .. code-block:: bash 92 | 93 | onshape-to-robot-mujoco my-robot 94 | 95 | What's next ? 96 | ------------- 97 | 98 | Before you can actually enjoy your export, you need to pay attention to the following: 99 | 100 | * ``onshape-to-robot`` comes with some conventions to follow, in order to understand what in your robot is a degree of freedom, a link, a frame, etc. Make sure to read the :doc:`design-time considerations `. 101 | * There are some options you might want to specify in the :doc:`config.json ` file. 102 | * Have a look at the `examples `_ available on GitHub. -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Onshape-to-robot documentation 2 | ============================== 3 | 4 | .. raw:: html 5 | 6 |
7 | 10 |
11 |
12 | 13 | 14 | What is this ? 15 | ~~~~~~~~~~~~~~ 16 | 17 | .. image:: _static/img/main.png 18 | 19 | ``onshape-to-robot`` is a tool that allows you to export robots designed from the **Onshape CAD** software 20 | to descriptions format like **URDF**, **SDF** or **MuJoCo**, so that you can use them for physics simulation or in your running code 21 | (requesting frames, computing dynamics etc.) 22 | 23 | * `onshape-to-robot GitHub repository `_ 24 | * `Robots examples GitHub repository `_ 25 | * `onshape-to-robot on pypi `_ 26 | * `video tutorial `_ (some information may be outdated) 27 | 28 | .. toctree:: 29 | :maxdepth: 2 30 | :caption: Contents: 31 | 32 | getting_started 33 | design 34 | config 35 | exporter_urdf 36 | exporter_sdf 37 | exporter_mujoco 38 | kinematic_loops 39 | processors 40 | cache 41 | -------------------------------------------------------------------------------- /docs/source/kinematic_loops.rst: -------------------------------------------------------------------------------- 1 | .. _kinematic-loops: 2 | 3 | Handling kinematic loops 4 | ======================== 5 | 6 | Some robots have *kinematic loops*, meaning that the kinematic chain is not a tree but a graph. 7 | 8 | Introduction 9 | ------------ 10 | 11 | Here is a 2D planar robot with kinematic loop, we assume the two first joints to be actuated and the others to 12 | be passive: 13 | 14 | .. raw:: html 15 | 16 |
17 | 20 |
21 |
22 | 23 | 24 | However, robot description are usually **trees**. To model this type of robot, we break it down to a tree. We attach **frames** to this tree, and need to enforce **run-time constraints**. 25 | 26 | .. image:: _static/img/opened_chain.png 27 | :width: 300px 28 | :align: center 29 | 30 | 31 | Specifying closing constraints 32 | ------------------------------ 33 | 34 | While you could manually add :ref:`frames `, ``onshape-to-robot`` provides a more convenient way to handle kinematic loops: **mate connectors**. 35 | 36 | To achieve that, add a **mate** with the name ``closing_something``: 37 | 38 | .. image:: _static/img/loop.png 39 | :align: center 40 | :class: padding 41 | 42 | 43 | Support for ```` in MuJoCo 44 | ------------------------------------ 45 | 46 | When using the :ref:`MuJoCo ` format, ``onshape-to-robot`` will add ```` constraints to enforce the kinematic loop. 47 | 48 | For example, the above robot can be exported using the following ``config.json``: 49 | 50 | .. code-block:: javascript 51 | 52 | { 53 | // Document URL, MuJoCo output 54 | "url": "https://cad.onshape.com/documents/04b05c47de7576f35c0e99b3/w/68041f3f5c827a258b40039c/e/db543f501b01adf8144064e3", 55 | "output_format": "mujoco", 56 | 57 | // Disable the freejoint to fix the robot 58 | "freejoint": false, 59 | 60 | // Don't create actuators for passive joints 61 | "joint_properties": { 62 | "passive1": {"actuated": false}, 63 | "passive2": {"actuated": false} 64 | } 65 | } 66 | 67 | Here is the result of the export: 68 | 69 | .. raw:: html 70 | 71 |
72 | 75 |
76 |
77 | 78 | 79 | Ressources 80 | ---------- 81 | 82 | Here are some ressources on how to handle kinematic loops in software: 83 | 84 | * `Onshape assembly `_ for the above example robot. 85 | * MuJoCo `equality `_ constraints. 86 | * In `pyBullet `_, you can use `createConstraint` method to add the relevant constraint. 87 | * In the `PlaCo `_ solver, you can create a `RelativePositionTask`. See the `kinematics loop documentation section `_ for more details. Some examples created with onshape-to-robot can be found in the `example gallery `_. 88 | -------------------------------------------------------------------------------- /docs/source/processor_ball_to_euler.rst: -------------------------------------------------------------------------------- 1 | Ball to Euler 2 | ============= 3 | 4 | Introduction 5 | ------------ 6 | 7 | This processor turns the ``ball`` joints to three revolute joints. This can be convenient if your downstream simulator or tool can't handle ``ball`` joints. 8 | 9 | ``config.json`` entries 10 | ----------------------- 11 | 12 | .. code-block:: javascript 13 | 14 | { 15 | // ... 16 | // General import options (see config.json documentation) 17 | // ... 18 | 19 | // Convert balls to Euler (default: false) 20 | "ball_to_euler": true, 21 | // Euler angles order (default: "xyz") 22 | "ball_to_euler_order": "xyz", 23 | } 24 | 25 | ``ball_to_euler`` *(default: false)* 26 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 27 | 28 | If set to ``true``, all ball joints will be converted to Euler. 29 | 30 | If you don't want to convert all your ball joints, you can specify a list of joints as follows: 31 | 32 | .. code-block:: javascript 33 | 34 | { 35 | // Using specific joint lists, with wildcards 36 | "ball_to_euler": ["joint1", "shoulder_*"], 37 | } 38 | 39 | ``ball_to_euler_order`` *(default: "xyz")* 40 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 41 | 42 | If set, the order of the euler angles will be set to the given value. The default is ``xyz``. 43 | The order should be ``xyz``, ``xzy``, ``zyx``, ``zxy``, ``yxz`` or ``yzx``. -------------------------------------------------------------------------------- /docs/source/processor_collision_as_visual.rst: -------------------------------------------------------------------------------- 1 | Use collisions as visual 2 | ======================== 3 | 4 | Introduction 5 | ------------ 6 | 7 | If this processor is enabled, the items from collision will be also used as visual items. 8 | 9 | This can be used for: 10 | 11 | * Debugging purpose, when you have no convenient way to visualize collisions in your downstream tools 12 | * Creating a model that is lighter to load 13 | 14 | 15 | ``config.json`` entries 16 | ----------------------- 17 | 18 | .. code-block:: javascript 19 | 20 | { 21 | // ... 22 | // General import options (see config.json documentation) 23 | // ... 24 | 25 | // Removes collision meshes 26 | "collisions_as_visual": true 27 | } 28 | 29 | ``collisions_as_visual`` *(default: false)* 30 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 31 | 32 | If set to ``true``, collision will be used as visual. -------------------------------------------------------------------------------- /docs/source/processor_convex_decomposition.rst: -------------------------------------------------------------------------------- 1 | Convex decomposition (CoACD) 2 | ============================ 3 | 4 | Introduction 5 | ------------ 6 | 7 | If this processor is enabled, the collision meshes will be decomposed using `CoACD `_ convex decomposition. 8 | 9 | 10 | ``config.json`` entries 11 | ----------------------- 12 | 13 | .. code-block:: javascript 14 | 15 | { 16 | // ... 17 | // General import options (see config.json documentation) 18 | // ... 19 | 20 | // Enables convex decomposition 21 | "convex_decomposition": true 22 | // Use Rainbow colors instead of the part color 23 | "rainbow_colors": true 24 | } 25 | 26 | ``convex_decomposition`` *(default: false)* 27 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 28 | 29 | If set to ``true``, collision meshes will be decomposed using CoACD. 30 | 31 | ``rainbow_colors`` *(default: false)* 32 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 33 | 34 | If set to ``true``, the collision meshes will be colored using rainbow colors instead of the part color. -------------------------------------------------------------------------------- /docs/source/processor_dummy_base_link.rst: -------------------------------------------------------------------------------- 1 | .. _processor_dummy_base_link: 2 | 3 | Adding dummy base link 4 | ====================== 5 | 6 | Introduction 7 | ------------ 8 | 9 | If this processor is enabled, a dummy base called ``base_link`` will be added in your robot. The base will be attached to this link using a ``fixed`` joint. 10 | 11 | ``config.json`` entries 12 | ----------------------- 13 | 14 | .. code-block:: javascript 15 | 16 | { 17 | // ... 18 | // General import options (see config.json documentation) 19 | // ... 20 | 21 | // Add a dummy base link (default: false) 22 | "add_dummy_base_link": true 23 | } 24 | 25 | ``add_dummy_base_link`` *(default: false)* 26 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 27 | 28 | If set to ``true``, a dummy base link will be added to the robot. -------------------------------------------------------------------------------- /docs/source/processor_fixed_links.rst: -------------------------------------------------------------------------------- 1 | Using fixed links 2 | ================= 3 | 4 | Introduction 5 | ------------ 6 | 7 | If this processor is enabled, all parts will be separated in a link, associated with its parent using a ``fixed`` link. 8 | 9 | .. note:: 10 | 11 | Doing this is likely to result in poor performance in physics engine, but can be useful for debugging. 12 | 13 | ``config.json`` entries 14 | ----------------------- 15 | 16 | .. code-block:: javascript 17 | 18 | { 19 | // ... 20 | // General import options (see config.json documentation) 21 | // ... 22 | 23 | // Adding fixed links, resulting in one link per part 24 | "use_fixed_links": true 25 | } 26 | 27 | ``use_fixed_links`` *(default: false)* 28 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 29 | 30 | If set to ``true``, a dummy base link will be added to the robot. 31 | 32 | Alternatively, ``use_fixed_links`` can be a list of the links for which you want the fixed links to be added. -------------------------------------------------------------------------------- /docs/source/processor_merge_parts.rst: -------------------------------------------------------------------------------- 1 | .. _processor-merge-parts: 2 | 3 | Merge STLs 4 | ========== 5 | 6 | Introduction 7 | ------------ 8 | 9 | This processor merge the meshes of all parts in the links into a single one. 10 | 11 | ``config.json`` entries 12 | ----------------------- 13 | 14 | .. code-block:: javascript 15 | 16 | { 17 | // ... 18 | // General import options (see config.json documentation) 19 | // ... 20 | 21 | // Merge STL meshes (default: false) 22 | "merge_stls": true 23 | } 24 | 25 | ``merge_stls`` *(default: false)* 26 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 27 | 28 | If set to ``true``, each link parts meshes will be merged in a single one. The resulting part will be named after the link. 29 | 30 | You can also set it to ``"visual"`` to merge only the visual parts, or to ``"collision"`` to merge only the collision parts. -------------------------------------------------------------------------------- /docs/source/processor_no_collision_meshes.rst: -------------------------------------------------------------------------------- 1 | Removing collision meshes 2 | ========================= 3 | 4 | Introduction 5 | ------------ 6 | 7 | This processor ensure no collision meshes are rendered. 8 | 9 | .. note:: 10 | 11 | Alternatively, you can use ignore lists to ignore meshes from being processed: 12 | 13 | .. code-block:: json 14 | 15 | { 16 | "ignore": { 17 | "*": "collision" 18 | } 19 | } 20 | 21 | However, this will prevent the meshes from being available to other processors (for example, to be approximated with pure shapes). 22 | 23 | 24 | ``config.json`` entries 25 | ----------------------- 26 | 27 | .. code-block:: javascript 28 | 29 | { 30 | // ... 31 | // General import options (see config.json documentation) 32 | // ... 33 | 34 | // Removes collision meshes 35 | "no_collision_meshes": true 36 | } 37 | 38 | ``no_collision_meshes`` *(default: false)* 39 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 40 | 41 | If set to ``true``, collision meshes will be removed. -------------------------------------------------------------------------------- /docs/source/processor_scad.rst: -------------------------------------------------------------------------------- 1 | OpenSCAD pure shapes approximation 2 | ================================== 3 | 4 | Introduction 5 | ------------ 6 | 7 | This processor provides you a way to manually approximate your robot into pure shapes. 8 | 9 | You can follow the following `video tutorial `_. Some of the steps are outdated, but the general idea is still the same. 10 | 11 | Requirements 12 | ------------ 13 | 14 | For this processor to work, you need to install the OpenSCAD package: 15 | 16 | .. code-block:: bash 17 | 18 | sudo apt-get install openscad 19 | 20 | Process 21 | ------- 22 | 23 | When the OpenSCAD processor is enabled (see below), it will check for the presence of ``.scad`` files in the output directory. If some are present, they will be parsed and pure shapes will be exported. 24 | 25 | .. note:: 26 | 27 | By default, exporters will use pure shapes for collisions instead of meshes. 28 | 29 | You can use the following convenient command to run OpenSCAD on a specific ``.stl`` you want to approximate: 30 | 31 | .. code-block:: bash 32 | 33 | onshape-to-robot-edit-shape 34 | 35 | This will open a window similar to the following: 36 | 37 | .. image:: _static/img/pure-shape.png 38 | :align: center 39 | :width: 400px 40 | 41 | Editing the ``.scad`` file with the same name as the ``.stl`` file. Pure shapes present here will be used as approximation. 42 | 43 | ``config.json`` entries 44 | ----------------------- 45 | 46 | .. code-block:: javascript 47 | 48 | { 49 | // ... 50 | // General import options (see config.json documentation) 51 | // ... 52 | 53 | // Simplify STL meshes (default: false) 54 | "use_scads": true, 55 | // Can be used to enlarge/shrink the pure shapes (default: 0.0) 56 | "pure_shape_dilatation": 0.0 57 | } 58 | 59 | ``use_scads`` *(default: false)* 60 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 61 | 62 | If set to ``true``, the processor will use OpenSCAD pure shapes approximation (see above) 63 | 64 | ``pure_shape_dilatation`` *(default: 0.0)* 65 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 66 | 67 | A float that can be used to enlarge or shrink the pure shapes. This can be useful to avoid collisions between parts. 68 | 69 | Use a negative value to shrink the shapes. -------------------------------------------------------------------------------- /docs/source/processor_simplify_stls.rst: -------------------------------------------------------------------------------- 1 | Simplify STLs 2 | ============= 3 | 4 | Introduction 5 | ------------ 6 | 7 | This processor will simplify the STLs files so that their size don't exceed a predefined limit. 8 | Used with :ref:`processor-merge-parts`, it will simplify the merged STLs. 9 | 10 | Requirements 11 | ------------ 12 | 13 | For this processor to work, ensure pymeshlab is installed: 14 | 15 | .. code-block:: bash 16 | 17 | pip install pymeshlab 18 | 19 | ``config.json`` entries 20 | ----------------------- 21 | 22 | .. code-block:: javascript 23 | 24 | { 25 | // ... 26 | // General import options (see config.json documentation) 27 | // ... 28 | 29 | // Simplify STL meshes (default: false) 30 | "simplify_stls": true, 31 | // Maximum size of the STL files in MB (default: 3) 32 | "max_stl_size": 1 33 | } 34 | 35 | ``simplify_stls`` *(default: false)* 36 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 37 | 38 | If set to ``true``, the STL files will be simplified. 39 | 40 | ``max_stl_size`` *(default: 3)* 41 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 42 | 43 | The maximum size of the STL files in MB. If the size of a file exceeds this limit, it will be simplified. 44 | 45 | You can also set it to ``"visual"`` to simplify only the visual parts, or to ``"collision"`` to simplify only the collision parts. -------------------------------------------------------------------------------- /docs/source/processors.rst: -------------------------------------------------------------------------------- 1 | Processors 2 | ========== 3 | 4 | Here is an overview of ``onshape-to-robot`` pipeline: 5 | 6 | .. image:: _static/img/architecture.png 7 | 8 | * **(1)**: The assembly is retrieved from Onshape, to produce an intermediate representation of the robot. See the `robot.py `_ from the source code. 9 | * **(2)**: Some operations can be applied on this representation, those are the **processors**, some of them are listed below. 10 | * **(3)**: The robot is exported to the desired format (URDF, MuJoCo, etc.) using an **exporter**. 11 | 12 | .. note:: 13 | 14 | If you want to tweak your robot in the process, do not hesitate to have a look at the `export.py `_ script, which is the entry point of the ``onshape-to-robot`` command, and summarize the above-listed steps. 15 | 16 | .. toctree:: 17 | :maxdepth: 2 18 | :caption: Processors: 19 | 20 | processor_ball_to_euler 21 | processor_merge_parts 22 | processor_simplify_stls 23 | processor_scad 24 | processor_dummy_base_link 25 | processor_no_collision_meshes 26 | processor_collision_as_visual 27 | processor_convex_decomposition 28 | processor_fixed_links 29 | custom_processors 30 | -------------------------------------------------------------------------------- /docs/watch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | make html 4 | echo "Starting watching..." 5 | killall php 6 | cd build/html 7 | php -S localhost:8080 & 8 | cd ../.. 9 | 10 | while [ true ] 11 | do 12 | inotifywait source/*.rst 13 | make html 14 | sleep 0.5 15 | done -------------------------------------------------------------------------------- /onshape_to_robot/__init__.py: -------------------------------------------------------------------------------- 1 | """Export models from Onshape to other robotics formats.""" 2 | 3 | from . import export, bullet, mujoco, clear_cache, edit_shape, pure_sketch 4 | -------------------------------------------------------------------------------- /onshape_to_robot/assets/scene.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 13 | 16 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /onshape_to_robot/bullet.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | import math 3 | import sys 4 | import os 5 | import time 6 | import argparse 7 | import pybullet as p 8 | from .simulation import Simulation 9 | 10 | parser = argparse.ArgumentParser(prog="onshape-to-robot-bullet") 11 | parser.add_argument("-f", "--fixed", action="store_true") 12 | parser.add_argument("-n", "--no-self-collisions", action="store_true") 13 | parser.add_argument("-x", "--x", type=float, default=0) 14 | parser.add_argument("-y", "--y", type=float, default=0) 15 | parser.add_argument("-z", "--z", type=float, default=0) 16 | parser.add_argument("directory") 17 | args = parser.parse_args() 18 | 19 | robotPath = args.directory 20 | if not robotPath.endswith(".urdf"): 21 | robotPath += "/robot.urdf" 22 | 23 | sim = Simulation( 24 | robotPath, 25 | gui=True, 26 | panels=True, 27 | fixed=args.fixed, 28 | ignore_self_collisions=args.no_self_collisions, 29 | ) 30 | pos, rpy = sim.getRobotPose() 31 | _, orn = p.getBasePositionAndOrientation(sim.robot) 32 | sim.setRobotPose([pos[0] + args.x, pos[1] + args.y, pos[2] + args.z], orn) 33 | 34 | controls = {} 35 | for name in sim.getJoints(): 36 | if name.endswith("_speed"): 37 | controls[name] = p.addUserDebugParameter(name, -math.pi * 3, math.pi * 3, 0) 38 | else: 39 | infos = sim.getJointsInfos(name) 40 | low = -math.pi 41 | high = math.pi 42 | if "lowerLimit" in infos: 43 | low = infos["lowerLimit"] 44 | if "upperLimit" in infos: 45 | high = infos["upperLimit"] 46 | controls[name] = p.addUserDebugParameter(name, low, high, 0) 47 | 48 | lastPrint = 0 49 | while True: 50 | targets = {} 51 | for name in controls.keys(): 52 | targets[name] = p.readUserDebugParameter(controls[name]) 53 | sim.setJoints(targets) 54 | 55 | if time.time() - lastPrint > 0.05: 56 | lastPrint = time.time() 57 | os.system("clear") 58 | frames = sim.getFrames() 59 | for frame in frames: 60 | print(frame) 61 | print("- x=%f\ty=%f\tz=%f" % frames[frame][0]) 62 | print("- r=%f\tp=%f\ty=%f" % frames[frame][1]) 63 | print("") 64 | print("Center of mass:") 65 | print(sim.getCenterOfMassPosition()) 66 | 67 | sim.tick() 68 | 69 | 70 | if __name__ == "__main__": 71 | main() 72 | -------------------------------------------------------------------------------- /onshape_to_robot/bullet/plane.obj: -------------------------------------------------------------------------------- 1 | # Blender v2.66 (sub 1) OBJ File: '' 2 | # www.blender.org 3 | mtllib plane.mtl 4 | o Plane 5 | v 15.000000 -15.000000 0.000000 6 | v 15.000000 15.000000 0.000000 7 | v -15.000000 15.000000 0.000000 8 | v -15.000000 -15.000000 0.000000 9 | 10 | vt 15.000000 0.000000 11 | vt 15.000000 15.000000 12 | vt 0.000000 15.000000 13 | vt 0.000000 0.000000 14 | 15 | usemtl Material 16 | s off 17 | f 1/1 2/2 3/3 18 | f 1/1 3/3 4/4 19 | -------------------------------------------------------------------------------- /onshape_to_robot/bullet/plane.urdf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /onshape_to_robot/clear_cache.py: -------------------------------------------------------------------------------- 1 | """Clear the onshape-to-robot cache.""" 2 | 3 | 4 | def main(): 5 | import shutil 6 | 7 | from .onshape_api.cache import get_cache_path 8 | 9 | """Clear the onshape-to-robot cache.""" 10 | cache_dir = get_cache_path() 11 | print("Removing cache directory: {}".format(cache_dir)) 12 | shutil.rmtree(cache_dir, ignore_errors=True) 13 | 14 | 15 | if __name__ == "__main__": 16 | main() 17 | -------------------------------------------------------------------------------- /onshape_to_robot/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from sys import exit 3 | import re 4 | import os 5 | import commentjson as json 6 | from .message import error, bright, info 7 | 8 | 9 | class Config: 10 | def __init__(self, robot_path: str): 11 | self.config_file: str = robot_path 12 | 13 | if os.path.isdir(robot_path): 14 | self.config_file += os.path.sep + "config.json" 15 | 16 | # Loading JSON configuration 17 | if not os.path.exists(self.config_file): 18 | raise Exception(f"ERROR: The file {self.config_file} can't be found") 19 | with open(self.config_file, "r", encoding="utf8") as stream: 20 | self.config: dict = json.load(stream) 21 | 22 | # Loaded processors 23 | self.processors: list = [] 24 | 25 | self.read_configuration() 26 | 27 | # Output directory, making it if it doesn't exists 28 | self.output_directory: str = os.path.dirname(os.path.abspath(self.config_file)) 29 | 30 | if self.robot_name is None: 31 | self.robot_name = os.path.dirname(os.path.abspath(self.config_file)).split( 32 | "/" 33 | )[-1] 34 | 35 | try: 36 | os.makedirs(self.output_directory) 37 | except OSError: 38 | pass 39 | 40 | def to_camel_case(self, snake_str: str) -> str: 41 | """ 42 | Converts a string to camel case 43 | """ 44 | components = snake_str.split("_") 45 | return components[0] + "".join(x.title() for x in components[1:]) 46 | 47 | def get(self, name: str, default=None, required: bool = True, values_list=None): 48 | """ 49 | Gets an entry from the configuration 50 | 51 | Args: 52 | name (str): entry name 53 | default: default fallback value if the entry is not present. Defaults to None. 54 | required (bool, optional): whether the configuration entry is required. Defaults to False. 55 | values_list: list of allowed values. Defaults to None. 56 | """ 57 | camel_name = self.to_camel_case(name) 58 | 59 | if name in self.config or camel_name in self.config: 60 | if name in self.config: 61 | value = self.config[name] 62 | else: 63 | value = self.config[camel_name] 64 | 65 | if values_list is not None and value not in values_list: 66 | raise Exception( 67 | f"Value for {name} should be onf of: {','.join(values_list)}" 68 | ) 69 | return value 70 | elif required and default is None: 71 | raise Exception(f"ERROR: missing required key {name} in config") 72 | 73 | return default 74 | 75 | def printable_version(self) -> str: 76 | if self.url is not None: 77 | return self.url 78 | else: 79 | version = f"document_id: {self.document_id}" 80 | if self.version_id: 81 | version += f" / version_id: {self.version_id}" 82 | elif self.workspace_id: 83 | version += f" / workspace_id: {self.workspace_id}" 84 | 85 | return version 86 | 87 | def parse_url(self): 88 | pattern = "https://(.*)/(.*)/([wv])/(.*)/e/(.*)" 89 | match = re.match(pattern, self.url) 90 | 91 | if match is None: 92 | raise Exception(f"Invalid URL: {self.url}") 93 | 94 | match_groups = match.groups() 95 | self.document_id = match_groups[1] 96 | if match_groups[2] == "w": 97 | self.workspace_id = match_groups[3] 98 | elif match_groups[2] == "v": 99 | self.version_id = match_groups[3] 100 | self.element_id = match_groups[4] 101 | 102 | def asset_path(self, asset_name: str) -> str: 103 | return f"{self.output_directory}/{self.assets_directory}/{asset_name}" 104 | 105 | def read_configuration(self): 106 | """ 107 | Load and check configuration entries 108 | """ 109 | 110 | # Robot name 111 | self.robot_name: str = self.get("robot_name", None, required=False) 112 | self.output_filename: str = self.get("output_filename", "robot") 113 | self.assets_directory: str = self.get("assets_directory", "assets") 114 | 115 | # Main settings 116 | self.document_id: str = self.get("document_id", required=False) 117 | self.version_id: str | None = self.get("version_id", required=False) 118 | self.workspace_id: str | None = self.get("workspace_id", required=False) 119 | self.element_id: str | None = self.get("element_id", required=False) 120 | 121 | if self.version_id and self.workspace_id: 122 | raise Exception("You can't specify workspace_id and version_id") 123 | 124 | self.url: str = self.get("url", None, required=False) 125 | if self.url is not None: 126 | self.parse_url() 127 | 128 | if self.url is None and self.document_id is None: 129 | raise Exception("You need to specify either a url or a document_id") 130 | 131 | self.draw_frames: bool = self.get("draw_frames", False) 132 | 133 | self.assembly_name: str = self.get("assembly_name", required=False) 134 | self.output_format: str = self.get("output_format") 135 | self.configuration: str | dict = self.get("configuration", "default") 136 | self.ignore_limits: bool = self.get("ignore_limits", False) 137 | 138 | if isinstance(self.configuration, dict): 139 | self.configuration = ";".join( 140 | [f"{k}={v}" for k, v in self.configuration.items()] 141 | ) 142 | 143 | # Joint specs 144 | self.joint_properties: dict = self.get("joint_properties", {}) 145 | self.no_dynamics: bool = self.get("no_dynamics", False) 146 | 147 | # Ignore / whitelists 148 | self.ignore: list[str] = self.get("ignore", {}) 149 | if isinstance(self.ignore, list): 150 | self.ignore = {entry: "all" for entry in self.ignore} 151 | 152 | # Color override 153 | self.color: str | None = self.get("color", required=False) 154 | 155 | # Post-import commands 156 | self.post_import_commands: list[str] = self.get("post_import_commands", []) 157 | 158 | # Whether to include configuration suffix in part names 159 | self.include_configuration_suffix: bool = self.get( 160 | "include_configuration_suffix", True 161 | ) 162 | 163 | # Loading processors 164 | from . import processors 165 | 166 | loaded_modules = {} 167 | processors_list: list[str] | None = self.get("processors", None, required=False) 168 | if processors_list is None: 169 | self.processors = [ 170 | processor(self) for processor in processors.default_processors 171 | ] 172 | else: 173 | for entry in processors_list: 174 | parts = entry.split(":") 175 | 176 | if len(parts) == 1: 177 | processor = eval(f"processors.{entry}") 178 | else: 179 | module, cls = parts 180 | if module not in loaded_modules: 181 | loaded_modules[module] = __import__(module, fromlist=[cls]) 182 | processor = getattr(loaded_modules[module], cls) 183 | 184 | if processor is None: 185 | raise Exception(f"ERROR: Processor {entry} not found") 186 | 187 | self.processors.append(processor(self)) 188 | -------------------------------------------------------------------------------- /onshape_to_robot/csg.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import os 4 | import numpy as np 5 | 6 | """ 7 | These functions are responsible for parsing CSG files (constructive solid geometry), which are files 8 | produced by OpenSCAD, containing no loop, variables etc. 9 | """ 10 | 11 | 12 | def multmatrix_parse(parameters): 13 | matrix = np.matrix(json.loads(parameters), dtype=float) 14 | matrix[0, 3] /= 1000.0 15 | matrix[1, 3] /= 1000.0 16 | matrix[2, 3] /= 1000.0 17 | return matrix 18 | 19 | 20 | def cube_parse(parameters, dilatation): 21 | results = re.findall(r'^size = (.+), center = (.+)$', parameters) 22 | if len(results) != 1: 23 | print("! Can't parse CSG cube parameters: "+parameters) 24 | exit() 25 | extra = np.array([dilatation]*3) 26 | return (extra + np.array(json.loads(results[0][0]), dtype=float)/1000.0), results[0][1] == 'true' 27 | 28 | 29 | def cylinder_parse(parameters, dilatation): 30 | results = re.findall( 31 | r'h = (.+), r1 = (.+), r2 = (.+), center = (.+)', parameters) 32 | if len(results) != 1: 33 | print("! Can't parse CSG cylinder parameters: "+parameters) 34 | exit() 35 | result = results[0] 36 | extra = np.array([dilatation/2, dilatation]) 37 | return (extra + np.array([result[0], result[1]], dtype=float)/1000.0), result[3] == 'true' 38 | 39 | 40 | def sphere_parse(parameters, dilatation): 41 | results = re.findall(r'r = (.+)$', parameters) 42 | if len(results) != 1: 43 | print("! Can't parse CSG sphere parameters: "+parameters) 44 | exit() 45 | return dilatation + float(results[0])/1000.0 46 | 47 | 48 | def extract_node_parameters(line): 49 | line = line.strip() 50 | parts = line.split('(', 1) 51 | node = parts[0] 52 | parameters = parts[1] 53 | if parameters[-1] == ';': 54 | parameters = parameters[:-2] 55 | if parameters[-1] == '{': 56 | parameters = parameters[:-3] 57 | return node, parameters 58 | 59 | 60 | def T(x, y, z): 61 | m = np.matrix(np.eye(4)) 62 | m.T[3, :3] = [x, y, z] 63 | 64 | return m 65 | 66 | 67 | def parse_csg(data, dilatation): 68 | shapes = [] 69 | lines = data.split("\n") 70 | matrices = [] 71 | for line in lines: 72 | line = line.strip() 73 | if line != '': 74 | if line[-1] == '{': 75 | node, parameters = extract_node_parameters(line) 76 | if node == 'multmatrix': 77 | matrix = multmatrix_parse(parameters) 78 | else: 79 | matrix = np.matrix(np.identity(4)) 80 | matrices.append(matrix) 81 | elif line[-1] == '}': 82 | matrices.pop() 83 | else: 84 | node, parameters = extract_node_parameters(line) 85 | transform = np.matrix(np.identity(4)) 86 | for entry in matrices: 87 | transform = transform*entry 88 | if node == 'cube': 89 | size, center = cube_parse(parameters, dilatation) 90 | if not center: 91 | transform = transform * \ 92 | T(size[0]/2.0, size[1]/2.0, size[2]/2.0) 93 | shapes.append({ 94 | 'type': 'cube', 95 | 'parameters': size, 96 | 'transform': transform 97 | }) 98 | if node == 'cylinder': 99 | size, center = cylinder_parse(parameters, dilatation) 100 | if not center: 101 | transform = transform * T(0, 0, size[0]/2.0) 102 | shapes.append({ 103 | 'type': 'cylinder', 104 | 'parameters': size, 105 | 'transform': transform 106 | }) 107 | if node == 'sphere': 108 | shapes.append({ 109 | 'type': 'sphere', 110 | 'parameters': sphere_parse(parameters, dilatation), 111 | 'transform': transform 112 | }) 113 | return shapes 114 | 115 | 116 | def process(filename, dilatation): 117 | tmp_data = os.getcwd()+'/_tmp_data.csg' 118 | os.system('openscad '+filename+' -o '+tmp_data) 119 | with open(tmp_data, "r", encoding="utf-8") as stream: 120 | data = stream.read() 121 | os.system('rm '+tmp_data) 122 | 123 | return parse_csg(data, dilatation) 124 | -------------------------------------------------------------------------------- /onshape_to_robot/edit_shape.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | import os 3 | import sys 4 | 5 | if len(sys.argv) < 2: 6 | print("Usage: onshape-to-robot-edit-shape {STL file}") 7 | else: 8 | fileName = sys.argv[1] 9 | parts = fileName.split(".") 10 | parts[-1] = "scad" 11 | fileName = ".".join(parts) 12 | if not os.path.exists(fileName): 13 | scad = '% scale(1000) import("' + os.path.basename(sys.argv[1]) + '");\n' 14 | scad += "\n" 15 | scad += "// Append pure shapes (cube, cylinder and sphere), e.g:\n" 16 | scad += "// cube([10, 10, 10], center=true);\n" 17 | scad += "// cylinder(r=10, h=10, center=true);\n" 18 | scad += "// sphere(10);\n" 19 | with open(fileName, "w", encoding="utf-8") as stream: 20 | stream.write(scad) 21 | directory = os.path.dirname(fileName) 22 | os.system("cd " + directory + "; openscad " + os.path.basename(fileName)) 23 | 24 | 25 | if __name__ == "__main__": 26 | main() 27 | -------------------------------------------------------------------------------- /onshape_to_robot/export.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | import os 3 | import sys 4 | import pickle 5 | from dotenv import load_dotenv, find_dotenv 6 | from .config import Config 7 | from .message import error, info 8 | from .robot_builder import RobotBuilder 9 | from .exporter_urdf import ExporterURDF 10 | from .exporter_sdf import ExporterSDF 11 | from .exporter_mujoco import ExporterMuJoCo 12 | 13 | """ 14 | This is the entry point of the export script, i.e the "onshape-to-robot" command. 15 | """ 16 | load_dotenv(find_dotenv(usecwd=True)) 17 | 18 | try: 19 | # Retrieving robot path 20 | if len(sys.argv) <= 1: 21 | raise Exception( 22 | "ERROR: usage: onshape-to-robot {robot_directory}\n" 23 | + "Read documentation at https://onshape-to-robot.readthedocs.io/" 24 | ) 25 | 26 | robot_path: str = sys.argv[1] 27 | 28 | # Loading configuration 29 | config = Config(robot_path) 30 | 31 | # Building exporter beforehand, so that the configuration gets checked 32 | if config.output_format == "urdf": 33 | exporter = ExporterURDF(config) 34 | elif config.output_format == "sdf": 35 | exporter = ExporterSDF(config) 36 | elif config.output_format == "mujoco": 37 | exporter = ExporterMuJoCo(config) 38 | else: 39 | raise Exception(f"Unsupported output format: {config.output_format}") 40 | 41 | # Building the robot 42 | robot_builder = RobotBuilder(config) 43 | robot = robot_builder.robot 44 | 45 | # Can be used for debugging 46 | # pickle.dump(robot, open("robot.pkl", "wb")) 47 | # robot = pickle.load(open("robot.pkl", "rb")) 48 | 49 | # Applying processors 50 | for processor in config.processors: 51 | processor.process(robot) 52 | 53 | exporter.write_xml( 54 | robot, 55 | config.output_directory + "/" + config.output_filename + "." + exporter.ext, 56 | ) 57 | 58 | for command in config.post_import_commands: 59 | print(info(f"* Running command: {command}")) 60 | os.system(command) 61 | 62 | except Exception as e: 63 | print(error(f"ERROR: {e}")) 64 | raise e 65 | 66 | 67 | if __name__ == "__main__": 68 | main() 69 | -------------------------------------------------------------------------------- /onshape_to_robot/exporter.py: -------------------------------------------------------------------------------- 1 | import os 2 | from .message import success 3 | import xml.dom.minidom 4 | from .robot import Robot 5 | 6 | 7 | class Exporter: 8 | def __init__(self): 9 | self.xml: str = "" 10 | self.ext: str = "xml" 11 | 12 | def build(self): 13 | raise Exception("This exporter should implement build() method") 14 | 15 | def get_xml(self, robot: Robot) -> str: 16 | self.build(robot) 17 | return self.xml 18 | 19 | def remove_empty_text_nodes(self, node: xml.dom.Node): 20 | to_delete = [] 21 | 22 | for child_node in node.childNodes: 23 | if isinstance(child_node, xml.dom.minidom.Text): 24 | child_node.data = child_node.data.strip() 25 | if child_node.data == "": 26 | to_delete.append(child_node) 27 | else: 28 | self.remove_empty_text_nodes(child_node) 29 | 30 | for child_node in to_delete: 31 | node.childNodes.remove(child_node) 32 | 33 | def write_xml(self, robot: Robot, filename: str) -> str: 34 | with open(filename, "w") as file: 35 | self.build(robot) 36 | dom = xml.dom.minidom.parseString(self.xml) 37 | self.remove_empty_text_nodes(dom) 38 | xml_output = dom.toprettyxml(indent=" ") 39 | file.write(xml_output) 40 | print(success(f"* Writing {os.path.basename(filename)}")) 41 | -------------------------------------------------------------------------------- /onshape_to_robot/exporter_mujoco.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import numpy as np 3 | import os 4 | import fnmatch 5 | from .message import success, warning, info 6 | from .robot import Robot, Link, Part, Joint, Closure 7 | from .config import Config 8 | from .geometry import Box, Cylinder, Sphere, Mesh, Shape 9 | from .exporter import Exporter 10 | from .exporter_utils import xml_escape, rotation_matrix_to_rpy 11 | from transforms3d.quaternions import mat2quat 12 | 13 | 14 | class ExporterMuJoCo(Exporter): 15 | def __init__(self, config: Config | None = None): 16 | super().__init__() 17 | self.config: Config = config 18 | 19 | self.no_dynamics: bool = False 20 | self.additional_xml: str = "" 21 | self.meshes: list = [] 22 | self.materials: dict = {} 23 | 24 | if config is not None: 25 | self.equalities = self.config.get("equalities", {}) 26 | self.no_dynamics = config.no_dynamics 27 | additional_xml_file = config.get("additional_xml", None, required=False) 28 | if isinstance(additional_xml_file, str): 29 | self.add_additional_xml(additional_xml_file) 30 | elif isinstance(additional_xml_file, list): 31 | for filename in additional_xml_file: 32 | self.add_additional_xml(filename) 33 | 34 | def add_additional_xml(self, xml_file: str): 35 | self.additional_xml += f"" 36 | with open(self.config.output_directory + "/" + xml_file, "r") as file: 37 | self.additional_xml += file.read() 38 | 39 | def append(self, line: str): 40 | self.xml += line 41 | 42 | def build(self, robot: Robot): 43 | self.xml = "" 44 | self.append('') 45 | self.append("") 46 | if self.config: 47 | self.append(f"") 48 | self.append(f'') 49 | self.append( 50 | f'' 51 | ) 52 | 53 | # Boilerplate 54 | self.default_class = robot.name 55 | self.append("") 56 | self.append(f'') 57 | self.append('') 58 | self.append('') 59 | self.append('') 60 | self.append('') 61 | self.append("") 62 | self.append('') 63 | self.append('') 64 | self.append("") 65 | self.append("") 66 | self.append("") 67 | 68 | if self.additional_xml: 69 | self.append(self.additional_xml) 70 | 71 | # Adding robot links 72 | self.append("") 73 | for base_link in robot.base_links: 74 | self.add_link(robot, base_link) 75 | self.append("") 76 | 77 | # Asset (mesh & materials) 78 | self.append("") 79 | for mesh_file in set(self.meshes): 80 | self.append(f'') 81 | for material_name, color in self.materials.items(): 82 | color_str = "%g %g %g 1" % tuple(color) 83 | self.append(f'') 84 | self.append("") 85 | 86 | # Adding actuators 87 | self.add_actuators(robot) 88 | 89 | # Adding equalities (loop closure) 90 | self.add_equalities(robot) 91 | 92 | self.append("") 93 | 94 | return self.xml 95 | 96 | def add_actuators(self, robot: Robot): 97 | self.append("") 98 | 99 | for joint in robot.joints: 100 | if joint.joint_type == "fixed": 101 | continue 102 | 103 | # Suppose joints with relation equality is not actuated, unless specified 104 | guess_actuated = joint.relation is None 105 | 106 | if ( 107 | joint.properties.get("actuated", guess_actuated) 108 | and joint.joint_type != Joint.BALL 109 | ): 110 | type = joint.properties.get("type", "position") 111 | actuator_class = joint.properties.get("class", self.default_class) 112 | actuator: str = ( 113 | f'<{type} class="{actuator_class}" name="{joint.name}" joint="{joint.name}" ' 114 | ) 115 | 116 | for key in "kp", "kv", "dampratio": 117 | if key in joint.properties: 118 | actuator += f'{key}="{joint.properties[key]}" ' 119 | 120 | if "forcerange" in joint.properties: 121 | actuator += f'forcerange="-{joint.properties["forcerange"]} {joint.properties["forcerange"]}" ' 122 | 123 | joint_limits = joint.properties.get("limits", joint.limits) 124 | limits_are_set = joint.properties.get("limits", False) != False 125 | if joint_limits and (type == "position" or limits_are_set): 126 | if joint.properties.get("range", True) and type == "position": 127 | actuator += f'inheritrange="1" ' 128 | else: 129 | actuator += f'ctrlrange="{joint_limits[0]} {joint_limits[1]}" ' 130 | 131 | actuator += "/>" 132 | self.append(actuator) 133 | 134 | self.append("") 135 | 136 | def get_equality_attributes(self, closure: Closure) -> str: 137 | all_attributes = {} 138 | for name, attributes in self.equalities.items(): 139 | if fnmatch.fnmatch(closure.frame1, name) and fnmatch.fnmatch( 140 | closure.frame2, name 141 | ): 142 | all_attributes.update(attributes) 143 | 144 | if len(all_attributes) > 0: 145 | return ( 146 | " ".join([f'{key}="{value}"' for key, value in all_attributes.items()]) 147 | + " " 148 | ) 149 | 150 | return "" 151 | 152 | def add_equalities(self, robot: Robot): 153 | self.append("") 154 | for closure in robot.closures: 155 | attributes = self.get_equality_attributes(closure) 156 | 157 | if closure.closure_type == Closure.FIXED: 158 | self.append( 159 | f'' 160 | ) 161 | elif closure.closure_type == Closure.REVOLUTE: 162 | self.append( 163 | f'' 164 | ) 165 | elif closure.closure_type == Closure.BALL: 166 | self.append( 167 | f'' 168 | ) 169 | else: 170 | print( 171 | warning( 172 | f"Closure type: {closure.closure_type} is not supported with MuJoCo equality constraints" 173 | ) 174 | ) 175 | 176 | for joint in robot.joints: 177 | if joint.relation is not None: 178 | self.append( 179 | f'' 180 | ) 181 | 182 | self.append("") 183 | 184 | def add_inertial(self, mass: float, com: np.ndarray, inertia: np.ndarray): 185 | # Ensuring epsilon masses and inertias 186 | mass = max(1e-9, mass) 187 | inertia[0, 0] = max(1e-9, inertia[0, 0]) 188 | inertia[1, 1] = max(1e-9, inertia[1, 1]) 189 | inertia[2, 2] = max(1e-9, inertia[2, 2]) 190 | 191 | # Populating body inertial properties 192 | # https://mujoco.readthedocs.io/en/stable/XMLreference.html#body-inertial 193 | inertial: str = "") 281 | if joint.joint_type == "fixed": 282 | self.append(f'') 283 | return 284 | 285 | joint_xml: str = "") 322 | T_link_frame = np.linalg.inv(T_world_link) @ T_world_frame 323 | 324 | site: str = f'") 348 | T_parent_link = np.linalg.inv(T_world_parent) @ T_world_link 349 | self.append( 350 | f'' 351 | ) 352 | 353 | if parent_joint is None: 354 | if not link.fixed: 355 | self.append(f'') 356 | else: 357 | self.add_joint(parent_joint) 358 | 359 | # Adding inertial properties 360 | mass, com, inertia = link.get_dynamics(T_world_link) 361 | self.add_inertial(mass, com, inertia) 362 | 363 | # Adding geometry objects 364 | for part in link.parts: 365 | self.append(f"") 366 | self.add_geometries(part, T_world_link) 367 | 368 | # Adding frames attached to current link 369 | for frame, T_world_frame in link.frames.items(): 370 | self.add_frame(frame, T_world_link, T_world_frame, group=3) 371 | 372 | # Adding joints and children links 373 | for joint in robot.get_link_joints(link): 374 | self.add_link(robot, joint.child, joint, T_world_link) 375 | 376 | self.append("") 377 | 378 | def pos_quat(self, matrix: np.ndarray) -> str: 379 | """ 380 | Turn a transformation matrix into 'pos="..." quat="..."' attributes 381 | """ 382 | pos = matrix[:3, 3] 383 | quat = mat2quat(matrix[:3, :3]) 384 | xml = 'pos="%g %g %g" quat="%g %g %g %g"' % (*pos, *quat) 385 | 386 | return xml 387 | 388 | def write_xml(self, robot: Robot, filename: str) -> str: 389 | super().write_xml(robot, filename) 390 | 391 | dirname = os.path.dirname(filename) 392 | scene_filename = dirname + "/scene.xml" 393 | if not os.path.exists(scene_filename): 394 | scene_xml: str = ( 395 | os.path.dirname(os.path.realpath(__file__)) + "/assets/scene.xml" 396 | ) 397 | scene_xml = open(scene_xml, "r").read() 398 | scene_xml = scene_xml.format(robot_filename=os.path.basename(filename)) 399 | with open(scene_filename, "w") as file: 400 | file.write(scene_xml) 401 | print(success(f"* Writing scene.xml")) 402 | else: 403 | print(info(f"* scene.xml already exists, not over-writing it")) 404 | -------------------------------------------------------------------------------- /onshape_to_robot/exporter_sdf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import numpy as np 3 | import os 4 | from .message import success 5 | from .robot import Robot, Link, Part, Joint 6 | from .config import Config 7 | from .geometry import Box, Cylinder, Sphere, Mesh, Shape 8 | from .exporter import Exporter 9 | from .exporter_utils import xml_escape, rotation_matrix_to_rpy 10 | 11 | MODEL_CONFIG_XML = """ 12 | 13 | 14 | %s 15 | 1.0 16 | %s 17 | 18 | 19 | 20 | 21 | 22 | 23 | """ 24 | 25 | 26 | class ExporterSDF(Exporter): 27 | def __init__(self, config: Config | None = None): 28 | super().__init__() 29 | self.config: Config = config 30 | 31 | self.ext: str = "sdf" 32 | self.no_dynamics: bool = False 33 | self.additional_xml: str = "" 34 | 35 | if config is not None: 36 | self.no_dynamics = config.no_dynamics 37 | additional_xml_file = config.get("additional_xml", None, required=False) 38 | if isinstance(additional_xml_file, str): 39 | self.add_additional_xml(additional_xml_file) 40 | elif isinstance(additional_xml_file, list): 41 | for filename in additional_xml_file: 42 | self.add_additional_xml(filename) 43 | 44 | def add_additional_xml(self, xml_file: str): 45 | self.additional_xml += f"" 46 | with open(self.config.output_directory + "/" + xml_file, "r") as file: 47 | self.additional_xml += file.read() 48 | 49 | def append(self, line: str): 50 | self.xml += line 51 | 52 | def build(self, robot: Robot): 53 | self.xml = "" 54 | self.append('') 55 | self.append("") 56 | if self.config: 57 | self.append(f"") 58 | self.append('') 59 | self.append(f'') 60 | 61 | for base_link in robot.base_links: 62 | self.add_link(robot, base_link) 63 | 64 | if self.additional_xml: 65 | self.append(self.additional_xml) 66 | 67 | self.append("") 68 | self.append("") 69 | 70 | return self.xml 71 | 72 | def add_inertial( 73 | self, mass: float, com: np.ndarray, inertia: np.ndarray, frame: str = "" 74 | ): 75 | # Unless "no_dynamics" is set, we make sure that mass and inertia 76 | # are not zero 77 | if not self.no_dynamics: 78 | mass = max(1e-9, mass) 79 | inertia[0, 0] = max(1e-9, inertia[0, 0]) 80 | inertia[1, 1] = max(1e-9, inertia[1, 1]) 81 | inertia[2, 2] = max(1e-9, inertia[2, 2]) 82 | 83 | self.append("") 84 | self.append( 85 | '%g %g %g 0 0 0' 86 | % ( 87 | com[0], 88 | com[1], 89 | com[2], 90 | ) 91 | ) 92 | self.append("%g" % mass) 93 | self.append( 94 | "%g%g%g%g%g%g" 95 | % ( 96 | inertia[0, 0], 97 | inertia[0, 1], 98 | inertia[0, 2], 99 | inertia[1, 1], 100 | inertia[1, 2], 101 | inertia[2, 2], 102 | ) 103 | ) 104 | self.append("") 105 | 106 | def append_material(self, color: np.ndarray): 107 | self.append(f"") 108 | self.append("%g %g %g 1.0" % (color[0], color[1], color[2])) 109 | self.append("%g %g %g 1.0" % (color[0], color[1], color[2])) 110 | self.append("0.1 0.1 0.1 1") 111 | self.append("0 0 0 0") 112 | self.append("") 113 | 114 | def add_mesh( 115 | self, 116 | link: Link, 117 | part: Part, 118 | node: str, 119 | T_world_link: np.ndarray, 120 | mesh: Mesh, 121 | mesh_n: int, 122 | ): 123 | """ 124 | Add a mesh node (e.g. STL) to the SDF file 125 | """ 126 | self.append(f'<{node} name="{part.name}_{node}_mesh_{mesh_n}">') 127 | 128 | T_link_part = np.linalg.inv(T_world_link) @ part.T_world_part 129 | self.append(self.pose(T_link_part, relative_to=link.name)) 130 | 131 | mesh_file = os.path.relpath(mesh.filename, self.config.output_directory) 132 | 133 | self.append("") 134 | self.append( 135 | f"model://{self.config.robot_name}/{xml_escape(mesh_file)}" 136 | ) 137 | self.append("") 138 | 139 | if node == "visual": 140 | self.append_material(mesh.color) 141 | 142 | self.append(f"") 143 | 144 | def add_shape( 145 | self, 146 | link: Link, 147 | part: Part, 148 | node: str, 149 | T_world_link: np.ndarray, 150 | shape: Shape, 151 | shape_n: int, 152 | ): 153 | """ 154 | Add shapes (box, sphere and cylinder) nodes to the SDF. 155 | """ 156 | self.append(f'<{node} name="{part.name}_{node}_shapes_{shape_n}">') 157 | 158 | T_link_shape = ( 159 | np.linalg.inv(T_world_link) @ part.T_world_part @ shape.T_part_shape 160 | ) 161 | self.append(self.pose(T_link_shape, relative_to=link.name)) 162 | 163 | self.append("") 164 | if isinstance(shape, Box): 165 | self.append("%g %g %g" % tuple(shape.size)) 166 | elif isinstance(shape, Cylinder): 167 | self.append( 168 | "%g%g" 169 | % (shape.length, shape.radius) 170 | ) 171 | elif isinstance(shape, Sphere): 172 | self.append("%g" % shape.radius) 173 | self.append("") 174 | 175 | if node == "visual": 176 | self.append_material(shape.color) 177 | 178 | self.append(f"") 179 | 180 | def add_geometries(self, link: Link, part: Part, T_world_link: np.ndarray): 181 | """ 182 | Add a part geometries 183 | """ 184 | shape_n = 0 185 | for shape in part.shapes: 186 | if shape.visual: 187 | shape_n += 1 188 | self.add_shape(link, part, "visual", T_world_link, shape, shape_n) 189 | if shape.collision: 190 | shape_n += 1 191 | self.add_shape(link, part, "collision", T_world_link, shape, shape_n) 192 | 193 | mesh_n = 0 194 | for mesh in part.meshes: 195 | if mesh.visual: 196 | mesh_n += 1 197 | self.add_mesh(link, part, "visual", T_world_link, mesh, mesh_n) 198 | if mesh.collision: 199 | mesh_n += 1 200 | self.add_mesh(link, part, "collision", T_world_link, mesh, mesh_n) 201 | 202 | def add_joint(self, joint: Joint, T_world_link: np.ndarray): 203 | self.append(f"") 204 | 205 | joint_type = joint.properties.get("type", joint.joint_type) 206 | self.append(f'') 207 | 208 | T_link_joint = np.linalg.inv(T_world_link) @ joint.T_world_joint 209 | self.append(self.pose(T_link_joint, relative_to=joint.parent.name)) 210 | 211 | self.append(f"{joint.parent.name}") 212 | self.append(f"{joint.child.name}") 213 | self.append("") 214 | self.append("%g %g %g" % tuple(joint.axis)) 215 | 216 | self.append("") 217 | if "max_effort" in joint.properties: 218 | self.append(f"%g" % joint.properties["max_effort"]) 219 | else: 220 | self.append(f"10") 221 | 222 | if "max_velocity" in joint.properties: 223 | self.append(f"%g" % joint.properties["max_velocity"]) 224 | else: 225 | self.append(f"10") 226 | 227 | joint_limits = joint.properties.get("limits", joint.limits) 228 | if joint_limits is None: 229 | if joint_type == "revolute": 230 | joint_limits = [-np.pi, np.pi] 231 | if joint_type == "prismatic": 232 | joint_limits = [-1, 1] 233 | 234 | self.append(f"{joint_limits[0]}") 235 | self.append(f"{joint_limits[1]}") 236 | self.append("") 237 | 238 | if joint.relation is not None: 239 | self.append( 240 | f'{joint.relation.ratio}' 241 | ) 242 | 243 | self.append("") 244 | self.append(f"{joint.properties.get('friction', 0.)}") 245 | self.append(f"{joint.properties.get('damping', 0.)}") 246 | self.append("") 247 | 248 | self.append("") 249 | 250 | self.append("") 251 | 252 | def add_frame( 253 | self, 254 | link: Link, 255 | frame: str, 256 | T_world_link: np.ndarray, 257 | T_world_frame: np.ndarray, 258 | ): 259 | self.append(f"") 260 | T_link_frame = np.linalg.inv(T_world_link) @ T_world_frame 261 | 262 | self.append(f'') 263 | self.append(self.pose(T_link_frame, relative_to=link.name)) 264 | self.append("") 265 | 266 | def add_link(self, robot: Robot, link: Link, joint: Joint = None): 267 | """ 268 | Adds a link recursively to the SDF file 269 | """ 270 | self.append(f"") 271 | self.append(f'') 272 | 273 | T_world_link = np.eye(4) 274 | if joint is not None: 275 | T_world_link = joint.T_world_joint 276 | 277 | # Adding inertial properties 278 | mass, com, inertia = link.get_dynamics(T_world_link) 279 | self.add_inertial(mass, com, inertia, link.name) 280 | 281 | relative_to = "" 282 | if joint is not None: 283 | relative_to = joint.name 284 | self.append(self.pose(np.eye(4), relative_to=relative_to)) 285 | 286 | # Adding geometry objects 287 | for part in link.parts: 288 | self.append(f"") 289 | self.add_geometries(link, part, T_world_link) 290 | 291 | self.append("") 292 | 293 | if link.fixed: 294 | self.append(f'') 295 | self.append(f"world") 296 | self.append(f"{link.name}") 297 | self.append("") 298 | 299 | # Adding frames attached to current link 300 | for frame, T_world_frame in link.frames.items(): 301 | self.add_frame(link, frame, T_world_link, T_world_frame) 302 | 303 | # Adding joints and children links 304 | for joint in robot.get_link_joints(link): 305 | self.add_link(robot, joint.child, joint) 306 | self.add_joint(joint, T_world_link) 307 | 308 | def pose(self, matrix: np.ndarray, relative_to: str = ""): 309 | """ 310 | Transforms a transformation matrix into a SDF pose tag 311 | """ 312 | relative = "" 313 | if relative_to: 314 | relative = f' relative_to="{relative_to}"' 315 | 316 | sdf = "%g %g %g %g %g %g" 317 | 318 | return sdf % (relative, *matrix[:3, 3], *rotation_matrix_to_rpy(matrix)) 319 | 320 | def write_xml(self, robot: Robot, filename: str) -> str: 321 | model_config = MODEL_CONFIG_XML % (robot.name, os.path.basename(filename)) 322 | 323 | super().write_xml(robot, filename) 324 | 325 | dirname = os.path.dirname(filename) 326 | with open(dirname + "/model.config", "w") as file: 327 | file.write(model_config) 328 | print(success(f"* Writing model.config")) 329 | -------------------------------------------------------------------------------- /onshape_to_robot/exporter_urdf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import numpy as np 3 | import os 4 | from .message import warning 5 | from .robot import Robot, Link, Part, Joint 6 | from .config import Config 7 | from .geometry import Box, Cylinder, Sphere, Shape, Mesh 8 | from .exporter import Exporter 9 | from .exporter_utils import xml_escape, rotation_matrix_to_rpy 10 | 11 | 12 | class ExporterURDF(Exporter): 13 | def __init__(self, config: Config | None = None): 14 | super().__init__() 15 | self.config: Config = config 16 | 17 | self.ext: str = "urdf" 18 | self.no_dynamics: bool = False 19 | self.package_name: str = "" 20 | self.additional_xml: str = "" 21 | self.set_zero_mass_to_fixed: bool = False 22 | 23 | if config is not None: 24 | self.no_dynamics = config.no_dynamics 25 | self.package_name: str = config.get("package_name", "") 26 | self.set_zero_mass_to_fixed: bool = config.get("set_zero_mass_to_fixed", False) 27 | additional_xml_file = config.get("additional_xml", None, required=False) 28 | if isinstance(additional_xml_file, str): 29 | self.add_additional_xml(additional_xml_file) 30 | elif isinstance(additional_xml_file, list): 31 | for filename in additional_xml_file: 32 | self.add_additional_xml(filename) 33 | 34 | def add_additional_xml(self, xml_file: str): 35 | self.additional_xml += f"" 36 | with open(self.config.output_directory + "/" + xml_file, "r") as file: 37 | self.additional_xml += file.read() 38 | 39 | def append(self, line: str): 40 | self.xml += line 41 | 42 | def build(self, robot: Robot): 43 | self.xml = "" 44 | self.append('') 45 | self.append("") 46 | if self.config: 47 | self.append(f"") 48 | self.append(f'') 49 | 50 | if len(robot.base_links) > 1: 51 | print( 52 | warning( 53 | "WARNING: Multiple base links detected, which is not supported by URDF." 54 | ) 55 | ) 56 | print(warning("Only the first base link will be considered.")) 57 | 58 | if len(robot.base_links) > 0: 59 | self.add_link(robot, robot.base_links[0]) 60 | 61 | if self.additional_xml: 62 | self.append(self.additional_xml) 63 | 64 | self.append("") 65 | 66 | return self.xml 67 | 68 | def add_inertial( 69 | self, mass: float, com: np.ndarray, inertia: np.ndarray, fixed: str = False 70 | ): 71 | # Unless "no_dynamics" is set, we make sure that mass and inertia 72 | # are not zero 73 | if not self.no_dynamics: 74 | mass = max(1e-9, mass) 75 | inertia[0, 0] = max(1e-9, inertia[0, 0]) 76 | inertia[1, 1] = max(1e-9, inertia[1, 1]) 77 | inertia[2, 2] = max(1e-9, inertia[2, 2]) 78 | if fixed and self.set_zero_mass_to_fixed: 79 | # To mark an object as fixed in the world, sets its dynamics to zero 80 | mass = 0 81 | com = np.zeros(3) 82 | inertia = np.zeros((3, 3)) 83 | 84 | self.append("") 85 | self.append( 86 | '' 87 | % ( 88 | com[0], 89 | com[1], 90 | com[2], 91 | ) 92 | ) 93 | self.append('' % mass) 94 | self.append( 95 | '' 96 | % ( 97 | inertia[0, 0], 98 | inertia[0, 1], 99 | inertia[0, 2], 100 | inertia[1, 1], 101 | inertia[1, 2], 102 | inertia[2, 2], 103 | ) 104 | ) 105 | self.append("") 106 | 107 | def add_mesh(self, part: Part, node: str, T_world_link: np.ndarray, mesh: Mesh): 108 | """ 109 | Add a mesh node (e.g. STL) to the URDF file 110 | """ 111 | self.append(f"<{node}>") 112 | 113 | T_link_part = np.linalg.inv(T_world_link) @ part.T_world_part 114 | self.append(self.origin(T_link_part)) 115 | 116 | mesh_file = os.path.relpath(mesh.filename, self.config.output_directory) 117 | if self.package_name: 118 | mesh_file = self.package_name + "/" + mesh_file 119 | 120 | self.append("") 121 | self.append(f'') 122 | self.append("") 123 | 124 | if node == "visual": 125 | material_name = f"{part.name}_material" 126 | self.append(f'') 127 | self.append( 128 | '' 129 | % (mesh.color[0], mesh.color[1], mesh.color[2]) 130 | ) 131 | self.append("") 132 | 133 | self.append(f"") 134 | 135 | def add_shape(self, part: Part, node: str, T_world_link: np.ndarray, shape: Shape): 136 | """ 137 | Add shapes (box, sphere and cylinder) nodes to the URDF. 138 | """ 139 | self.append(f"<{node}>") 140 | 141 | T_link_shape = ( 142 | np.linalg.inv(T_world_link) @ part.T_world_part @ shape.T_part_shape 143 | ) 144 | self.append(self.origin(T_link_shape)) 145 | 146 | self.append("") 147 | if isinstance(shape, Box): 148 | self.append('' % tuple(shape.size)) 149 | elif isinstance(shape, Cylinder): 150 | self.append( 151 | '' % (shape.length, shape.radius) 152 | ) 153 | elif isinstance(shape, Sphere): 154 | self.append('' % shape.radius) 155 | self.append("") 156 | 157 | if node == "visual": 158 | material_name = f"{part.name}_material" 159 | self.append(f'') 160 | self.append( 161 | '' 162 | % (shape.color[0], shape.color[1], shape.color[2]) 163 | ) 164 | self.append("") 165 | 166 | self.append(f"") 167 | 168 | def add_geometries(self, part: Part, T_world_link: np.ndarray): 169 | """ 170 | Add a part geometries 171 | """ 172 | for shape in part.shapes: 173 | if shape.visual: 174 | self.add_shape(part, "visual", T_world_link, shape) 175 | if shape.collision: 176 | self.add_shape(part, "collision", T_world_link, shape) 177 | 178 | for mesh in part.meshes: 179 | if mesh.visual: 180 | self.add_mesh(part, "visual", T_world_link, mesh) 181 | if mesh.collision: 182 | self.add_mesh(part, "collision", T_world_link, mesh) 183 | 184 | def add_joint(self, joint: Joint, T_world_link: np.ndarray): 185 | self.append(f"") 186 | 187 | joint_type = joint.properties.get("type", joint.joint_type) 188 | self.append(f'') 189 | 190 | T_link_joint = np.linalg.inv(T_world_link) @ joint.T_world_joint 191 | self.append(self.origin(T_link_joint)) 192 | 193 | self.append(f'') 194 | self.append(f'') 195 | self.append('' % tuple(joint.axis)) 196 | 197 | limits = "" 198 | if "max_effort" in joint.properties: 199 | limits += 'effort="%g" ' % joint.properties["max_effort"] 200 | else: 201 | limits += 'effort="10" ' 202 | 203 | if "max_velocity" in joint.properties: 204 | limits += 'velocity="%g" ' % joint.properties["max_velocity"] 205 | else: 206 | limits += 'velocity="10" ' 207 | 208 | joint_limits = joint.properties.get("limits", joint.limits) 209 | if joint_limits is not None: 210 | limits += 'lower="%g" upper="%g" ' % (joint_limits[0], joint_limits[1]) 211 | elif joint_type == "revolute": 212 | limits += f'lower="{-np.pi}" upper="{np.pi}" ' 213 | elif joint_type == "prismatic": 214 | limits += 'lower="-1" upper="1" ' 215 | 216 | if limits: 217 | self.append(f"") 218 | 219 | if "friction" in joint.properties: 220 | self.append(f'') 221 | 222 | if joint.relation is not None: 223 | self.append( 224 | f'' 225 | ) 226 | 227 | self.append("") 228 | 229 | def add_frame( 230 | self, 231 | link: Link, 232 | frame: str, 233 | T_world_link: np.ndarray, 234 | T_world_frame: np.ndarray, 235 | ): 236 | self.append(f"") 237 | T_link_frame = np.linalg.inv(T_world_link) @ T_world_frame 238 | 239 | # Adding a dummy link to the assembly 240 | self.append(f'') 241 | self.append(self.origin(np.eye(4))) 242 | 243 | self.append("") 244 | self.append('') 245 | if self.no_dynamics: 246 | self.append('') 247 | else: 248 | self.append('') 249 | self.append('') 250 | self.append("") 251 | 252 | self.append("") 253 | 254 | # Attaching this dummy link to the parent frame using a fixed joint 255 | self.append(f'') 256 | self.append(self.origin(T_link_frame)) 257 | self.append(f'') 258 | self.append(f'') 259 | self.append('') 260 | self.append("") 261 | 262 | def add_link(self, robot: Robot, link: Link, T_world_link: np.ndarray = np.eye(4)): 263 | """ 264 | Adds a link recursively to the URDF file 265 | """ 266 | self.append(f"") 267 | self.append(f'') 268 | 269 | # Adding inertial properties 270 | mass, com, inertia = link.get_dynamics(T_world_link) 271 | self.add_inertial(mass, com, inertia, link.fixed) 272 | 273 | # Adding geometry objects 274 | for part in link.parts: 275 | self.append(f"") 276 | self.add_geometries(part, T_world_link) 277 | 278 | self.append("") 279 | 280 | # Adding frames attached to current link 281 | for frame, T_world_frame in link.frames.items(): 282 | self.add_frame(link, frame, T_world_link, T_world_frame) 283 | 284 | # Adding joints and children links 285 | for joint in robot.get_link_joints(link): 286 | self.add_link(robot, joint.child, joint.T_world_joint) 287 | self.add_joint(joint, T_world_link) 288 | 289 | def origin(self, matrix: np.ndarray): 290 | """ 291 | Transforms a transformation matrix into a URDF origin tag 292 | """ 293 | urdf = '' 294 | 295 | return urdf % (*matrix[:3, 3], *rotation_matrix_to_rpy(matrix)) 296 | -------------------------------------------------------------------------------- /onshape_to_robot/exporter_utils.py: -------------------------------------------------------------------------------- 1 | import math 2 | import numpy as np 3 | from xml.sax.saxutils import escape 4 | 5 | 6 | def xml_escape(unescaped: str) -> str: 7 | """ 8 | Escapes XML characters in a string so that it can be safely added to an XML file 9 | """ 10 | return escape(unescaped, entities={"'": "'", '"': """}) 11 | 12 | 13 | def rotation_matrix_to_rpy(R): 14 | """ 15 | Converts a rotation matrix to rpy Euler angles 16 | """ 17 | sy = math.sqrt(R[0, 0] * R[0, 0] + R[1, 0] * R[1, 0]) 18 | 19 | singular = sy < 1e-6 20 | 21 | if not singular: 22 | x = math.atan2(R[2, 1], R[2, 2]) 23 | y = math.atan2(-R[2, 0], sy) 24 | z = math.atan2(R[1, 0], R[0, 0]) 25 | else: 26 | x = math.atan2(-R[1, 2], R[1, 1]) 27 | y = math.atan2(-R[2, 0], sy) 28 | z = 0 29 | 30 | return np.array([x, y, z]) 31 | -------------------------------------------------------------------------------- /onshape_to_robot/expression.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import ast 3 | import operator as op 4 | 5 | 6 | class ExpressionParser: 7 | """ 8 | Evaluate Onshape expression 9 | See https://cad.onshape.com/help/Content/numeric-fields.htm 10 | 11 | This parser is based on ast module. 12 | """ 13 | 14 | def __init__(self): 15 | self.variables_lazy_loading = None 16 | self.variables = { 17 | "pi": np.pi, 18 | } 19 | 20 | # Supported operators 21 | operators = { 22 | ast.Add: op.add, 23 | ast.Sub: op.sub, 24 | ast.Mult: op.mul, 25 | ast.Div: op.truediv, 26 | ast.Pow: op.pow, 27 | ast.BitXor: op.xor, 28 | ast.USub: op.neg, 29 | ast.Mod: op.mod, 30 | } 31 | 32 | # Supporter functions 33 | functions = { 34 | "cos": np.cos, 35 | "sin": np.sin, 36 | "tan": np.tan, 37 | "acos": np.arccos, 38 | "asin": np.arcsin, 39 | "atan": np.arctan, 40 | "atan2": np.arctan2, 41 | "cosh": np.cosh, 42 | "sinh": np.sinh, 43 | "tanh": np.tanh, 44 | "asinh": np.arcsinh, 45 | "acosh": np.arccosh, 46 | "atanh": np.arctanh, 47 | "ceil": np.ceil, 48 | "floor": np.floor, 49 | "round": np.round, 50 | "exp": np.exp, 51 | "sqrt": np.sqrt, 52 | "abs": np.abs, 53 | "max": np.max, 54 | "min": np.min, 55 | "log": np.log, 56 | "log10": np.log10, 57 | } 58 | 59 | def eval_expr(self, expr): 60 | # Length units. Converting everything to meter / radian 61 | units = { 62 | "millimeter": 1e-3, 63 | "mm": 1e-3, 64 | "centimeter": 1e-2, 65 | "cm": 1e-2, 66 | "meter": 1.0, 67 | "inch": 0.0254, 68 | "in": 0.0254, 69 | "foot": 0.3048, 70 | "ft": 0.3048, 71 | "yard": 0.9144, 72 | "yd": 0.9144, 73 | "radian": 1.0, 74 | "rad": 1.0, 75 | "degree": np.pi / 180, 76 | "deg": np.pi / 180, 77 | } 78 | for unit, factor in units.items(): 79 | expr = expr.replace(f" {unit}", f"*{factor}") 80 | 81 | expr = expr.replace("#", "") 82 | expr = expr.replace("^", "**") 83 | 84 | return self.eval_(ast.parse(expr, mode="eval").body) 85 | 86 | def eval_(self, node): 87 | if isinstance(node, ast.Constant): 88 | return float(node.value) 89 | elif isinstance(node, ast.BinOp): 90 | return self.operators[type(node.op)]( 91 | self.eval_(node.left), self.eval_(node.right) 92 | ) 93 | elif isinstance(node, ast.UnaryOp): # e.g., -1 94 | return self.operators[type(node.op)](self.eval_(node.operand)) 95 | elif isinstance(node, ast.Name): 96 | if ( 97 | node.id.lower() not in self.variables 98 | and self.variables_lazy_loading is not None 99 | ): 100 | self.variables_lazy_loading() 101 | self.variables_lazy_loading = None 102 | if node.id.lower() not in self.variables: 103 | raise ValueError(f"Unknown variable in expression: {node.id}") 104 | return self.variables[node.id.lower()] 105 | elif isinstance(node, ast.Call): 106 | if node.func.id not in self.functions: 107 | raise ValueError(f"Unknown function in expression: {node.func.id}") 108 | return self.functions[node.func.id](*[self.eval_(arg) for arg in node.args]) 109 | else: 110 | raise TypeError(node) 111 | 112 | 113 | if __name__ == "__main__": 114 | ep = ExpressionParser() 115 | ep.variables["x"] = 5 116 | 117 | print(ep.eval_expr("(cos(5 deg)) mm + #x inch")) 118 | print(ep.eval_expr("-sin(3/(2^2) deg)")) 119 | -------------------------------------------------------------------------------- /onshape_to_robot/geometry.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | class Geometry: 5 | def __init__( 6 | self, 7 | color: np.ndarray = np.array([0.5, 0.5, 0.5]), 8 | visual: bool = True, 9 | collision: bool = True, 10 | ): 11 | self.color: np.ndarray = color 12 | self.visual: bool = visual 13 | self.collision: bool = collision 14 | 15 | def is_type(self, what: str): 16 | if what == "visual": 17 | return self.visual 18 | elif what == "collision": 19 | return self.collision 20 | return False 21 | 22 | 23 | class Mesh(Geometry): 24 | def __init__( 25 | self, 26 | filename: str, 27 | color: np.ndarray = np.array([0.5, 0.5, 0.5]), 28 | visual: bool = True, 29 | collision: bool = True, 30 | ): 31 | super().__init__(color, visual, collision) 32 | self.filename: str = filename 33 | 34 | 35 | class Shape(Geometry): 36 | def __init__( 37 | self, 38 | T_part_shape: np.ndarray, 39 | color: np.ndarray = np.array([0.5, 0.5, 0.5]), 40 | visual: bool = True, 41 | collision: bool = True, 42 | ): 43 | super().__init__(color, visual, collision) 44 | self.T_part_shape: np.ndarray = T_part_shape 45 | 46 | 47 | class Box(Shape): 48 | def __init__( 49 | self, 50 | T_part_shape: np.ndarray, 51 | size: np.ndarray, 52 | color: np.ndarray = np.array([0.5, 0.5, 0.5]), 53 | visual: bool = True, 54 | collision: bool = True, 55 | ): 56 | super().__init__(T_part_shape, color, visual, collision) 57 | self.size: np.ndarray = size 58 | 59 | 60 | class Cylinder(Shape): 61 | def __init__( 62 | self, 63 | T_part_shape: np.ndarray, 64 | length: float, 65 | radius: float, 66 | color: np.ndarray = np.array([0.5, 0.5, 0.5]), 67 | visual: bool = True, 68 | collision: bool = True, 69 | ): 70 | super().__init__(T_part_shape, color, visual, collision) 71 | self.length: float = length 72 | self.radius: float = radius 73 | 74 | 75 | class Sphere(Shape): 76 | def __init__( 77 | self, 78 | T_part_shape: np.ndarray, 79 | radius: float, 80 | color: np.ndarray = np.array([0.5, 0.5, 0.5]), 81 | visual: bool = True, 82 | collision: bool = True, 83 | ): 84 | super().__init__(T_part_shape, color, visual, collision) 85 | self.radius: float = radius 86 | -------------------------------------------------------------------------------- /onshape_to_robot/message.py: -------------------------------------------------------------------------------- 1 | from colorama import Fore, Back, Style, just_fix_windows_console 2 | 3 | just_fix_windows_console() 4 | 5 | def error(text: str): 6 | return Fore.RED + text + Style.RESET_ALL 7 | 8 | def bright(text: str): 9 | return Style.BRIGHT + text + Style.RESET_ALL 10 | 11 | def info(text: str): 12 | return Fore.BLUE + text + Style.RESET_ALL 13 | 14 | def success(text: str): 15 | return Fore.GREEN + text + Style.RESET_ALL 16 | 17 | def warning(text: str): 18 | return Fore.YELLOW + text + Style.RESET_ALL 19 | 20 | def dim(text: str): 21 | return Style.DIM + text + Style.RESET_ALL 22 | -------------------------------------------------------------------------------- /onshape_to_robot/mujoco.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | import time 3 | import mujoco 4 | import argparse 5 | import mujoco.viewer 6 | 7 | parser = argparse.ArgumentParser(prog="onshape-to-robot-mujoco") 8 | parser.add_argument("--sim", action="store_true") 9 | parser.add_argument("--x", type=float, default=0) 10 | parser.add_argument("--y", type=float, default=0) 11 | parser.add_argument("--z", type=float, default=0.5) 12 | parser.add_argument("directory") 13 | args = parser.parse_args() 14 | 15 | robot_path = args.directory 16 | if not robot_path.endswith(".xml"): 17 | robot_path += "/scene.xml" 18 | 19 | model: mujoco.MjModel = mujoco.MjModel.from_xml_path(robot_path) 20 | data: mujoco.MjData = mujoco.MjData(model) 21 | 22 | # Check for root existence 23 | if len(model.jnt_type) and model.jnt_type[0] == mujoco.mjtJoint.mjJNT_FREE: 24 | data.qpos[:3] = [args.x, args.y, args.z] 25 | 26 | viewer = mujoco.viewer.launch_passive(model, data) 27 | while viewer.is_running(): 28 | step_start = time.time() 29 | mujoco.mj_step(model, data) 30 | viewer.sync() 31 | 32 | time_until_next_step = model.opt.timestep - (time.time() - step_start) 33 | if time_until_next_step > 0: 34 | time.sleep(time_until_next_step) 35 | 36 | 37 | if __name__ == "__main__": 38 | main() 39 | -------------------------------------------------------------------------------- /onshape_to_robot/onshape_api/__init__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | onshape_api, based on onshape "apikey" 3 | ====== 4 | 5 | Demonstrates usage of API keys for the Onshape REST API 6 | ''' 7 | 8 | __copyright__ = 'Copyright (c) 2016 Onshape, Inc.' 9 | __license__ = 'All rights reserved.' 10 | __title__ = 'onshape_api' 11 | __all__ = ['onshape', 'client', 'utils'] 12 | -------------------------------------------------------------------------------- /onshape_to_robot/onshape_api/cache.py: -------------------------------------------------------------------------------- 1 | import os 2 | import inspect 3 | import hashlib 4 | from pathlib import Path 5 | import pickle 6 | 7 | 8 | def get_cache_path() -> Path: 9 | """ 10 | Return the path to the user cache. 11 | """ 12 | path = Path.home() / ".cache" / "onshape-to-robot" 13 | path.mkdir(parents=True, exist_ok=True) 14 | return path 15 | 16 | 17 | def can_cache(method, *args, **kwargs) -> bool: 18 | """ 19 | Check if the cache can be used. 20 | When using wmv=w, the current workspace is used, which make it impossible to cache. 21 | """ 22 | signature = inspect.signature(method) 23 | wmv = None 24 | if "wmv" in signature.parameters: 25 | wmv = signature.parameters["wmv"].default 26 | if "wmv" in kwargs: 27 | wmv = kwargs["wmv"] 28 | return wmv != "w" 29 | 30 | 31 | def cache_response(method): 32 | """ 33 | Decorator that caches the response of a method. 34 | """ 35 | 36 | def cached_call(*args, **kwargs): 37 | # Checking if the method can be cached 38 | if not can_cache(method, *args, **kwargs): 39 | return method(*args, **kwargs) 40 | 41 | # Building filename that is unique for method/args combination 42 | method_name = method.__qualname__ 43 | arguments = {"args": args, "kwargs": kwargs} 44 | arguments_hash = hashlib.sha1(pickle.dumps(arguments)).hexdigest() 45 | filename = f"{get_cache_path()}/{method_name}_{arguments_hash}.pkl" 46 | 47 | if not os.path.exists(filename): 48 | result = method(*args, **kwargs) 49 | with open(filename, "wb") as f: 50 | pickle.dump(result, f) 51 | 52 | return pickle.load(open(filename, "rb")) 53 | 54 | return cached_call 55 | -------------------------------------------------------------------------------- /onshape_to_robot/onshape_api/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | client 3 | ====== 4 | 5 | Convenience functions for working with the Onshape API 6 | """ 7 | 8 | from .onshape import Onshape 9 | from .cache import cache_response 10 | 11 | 12 | def escape(s): 13 | return s.replace("/", "%2f").replace("+", "%2b") 14 | 15 | 16 | class Client: 17 | """ 18 | Defines methods for testing the Onshape API. Comes with several methods: 19 | 20 | - Create a document 21 | - Delete a document 22 | - Get a list of documents 23 | 24 | Attributes: 25 | - stack (str, default='https://cad.onshape.com'): Base URL 26 | - logging (bool, default=True): Turn logging on or off 27 | """ 28 | 29 | def __init__( 30 | self, stack="https://cad.onshape.com", logging=True, creds="./config.json" 31 | ): 32 | """ 33 | Instantiates a new Onshape client. 34 | 35 | Args: 36 | - stack (str, default='https://cad.onshape.com'): Base URL 37 | - logging (bool, default=True): Turn logging on or off 38 | """ 39 | 40 | self._metadata_cache = {} 41 | self._massproperties_cache = {} 42 | self._stack = stack 43 | self._api = Onshape(stack=stack, logging=logging, creds=creds) 44 | 45 | def request(self, url, **kwargs): 46 | return self._api.request("get", url, **kwargs).json() 47 | 48 | def request_binary(self, url, **kwargs): 49 | return self._api.request("get", url, **kwargs).content 50 | 51 | @cache_response 52 | def get_document(self, did): 53 | """ 54 | Get details for a specified document. 55 | 56 | Args: 57 | - did (str): Document ID 58 | 59 | Returns: 60 | - requests.Response: Onshape response data 61 | """ 62 | return self.request(f"/api/documents/{escape(did)}") 63 | 64 | @cache_response 65 | def list_elements(self, did, wid, wmv="w"): 66 | """ 67 | Get the list of elements in a given document 68 | """ 69 | 70 | return self.request( 71 | f"/api/documents/d/{escape(did)}/{escape(wmv)}/{escape(wid)}/elements" 72 | ) 73 | 74 | @cache_response 75 | def get_assembly(self, did, wmvid, eid, wmv="w", configuration="default"): 76 | """ 77 | Retrieve the assembly structure for a specified document / workspace / element. 78 | """ 79 | return self.request( 80 | f"/api/assemblies/d/{escape(did)}/{escape(wmv)}/{escape(wmvid)}/e/{escape(eid)}", 81 | query={ 82 | "includeMateFeatures": "true", 83 | "includeMateConnectors": "true", 84 | "includeNonSolids": "true", 85 | "configuration": configuration, 86 | }, 87 | ) 88 | 89 | @cache_response 90 | def get_features(self, did, wvid, eid, wmv="w", configuration="default"): 91 | """ 92 | Gets the feature list for specified document / workspace / part studio. 93 | 94 | Args: 95 | - did (str): Document ID 96 | - mid (str): Microversion 97 | - eid (str): Element ID 98 | 99 | Returns: 100 | - requests.Response: Onshape response data 101 | """ 102 | 103 | return self.request( 104 | f"/api/assemblies/d/{escape(did)}/{escape(wmv)}/{escape(wvid)}/e/{escape(eid)}/features", 105 | query={"configuration": configuration}, 106 | ) 107 | 108 | @cache_response 109 | def get_sketches(self, did, mid, eid, configuration): 110 | """ 111 | Get sketches for a given document / microversion / element. 112 | """ 113 | return self.request( 114 | f"/api/partstudios/d/{escape(did)}/m/{escape(mid)}/e/{escape(eid)}/sketches", 115 | query={"includeGeometry": "true", "configuration": configuration}, 116 | ) 117 | 118 | @cache_response 119 | def get_parts(self, did, mid, eid, configuration): 120 | """ 121 | Get parts for a given document / microversion / element. 122 | """ 123 | return self.request( 124 | f"/api/parts/d/{escape(did)}/m/{escape(mid)}/e/{escape(eid)}", 125 | query={"configuration": configuration}, 126 | ) 127 | 128 | def find_new_partid( 129 | self, did, mid, eid, partid, configuration_before, configuration 130 | ): 131 | before = self.get_parts(did, mid, eid, configuration_before) 132 | name = None 133 | for entry in before: 134 | if entry["partId"] == partid: 135 | name = entry["name"] 136 | 137 | if name is not None: 138 | after = self.get_parts(did, mid, eid, configuration) 139 | for entry in after: 140 | if entry["name"] == name: 141 | return entry["partId"] 142 | else: 143 | print("Onshape ERROR: Can't find new partid for " + str(partid)) 144 | 145 | return partid 146 | 147 | @cache_response 148 | def part_studio_stl_m( 149 | self, 150 | did, 151 | wmvid, 152 | eid, 153 | partid="", 154 | wmv="m", 155 | configuration="default", 156 | linked_document_id=None, 157 | ): 158 | req_headers = {"Accept": "*/*"} 159 | query = { 160 | "mode": "binary", 161 | "units": "meter", 162 | "configuration": configuration, 163 | } 164 | if linked_document_id is not None: 165 | query["linkDocumentId"] = linked_document_id 166 | return self.request_binary( 167 | f"/api/parts/d/{escape(did)}/{escape(wmv)}/{escape(wmvid)}/e/{escape(eid)}/partid/{escape(partid)}/stl", 168 | query=query, 169 | headers=req_headers, 170 | ) 171 | 172 | @cache_response 173 | def matevalues(self, did, wmvid, eid, wmv="w", configuration="default"): 174 | return self.request( 175 | f"/api/assemblies/d/{escape(did)}/{wmv}/{escape(wmvid)}/e/{escape(eid)}/matevalues", 176 | query={"configuration": configuration}, 177 | ) 178 | 179 | @cache_response 180 | def part_get_metadata( 181 | self, 182 | did, 183 | wmvid, 184 | eid, 185 | partid, 186 | wmv="m", 187 | configuration="default", 188 | linked_document_id=None, 189 | ): 190 | query = {"configuration": configuration} 191 | if linked_document_id is not None: 192 | query["linkDocumentId"] = linked_document_id 193 | return self.request( 194 | f"/api/metadata/d/{escape(did)}/{escape(wmv)}/{escape(wmvid)}/e/{escape(eid)}/p/{escape(partid)}", 195 | query=query, 196 | ) 197 | 198 | @cache_response 199 | def part_mass_properties( 200 | self, 201 | did, 202 | wmvid, 203 | eid, 204 | partid, 205 | wmv="m", 206 | configuration="default", 207 | linked_document_id=None, 208 | ): 209 | query = { 210 | "configuration": configuration, 211 | "useMassPropertyOverrides": True, 212 | } 213 | if linked_document_id is not None: 214 | query["linkDocumentId"] = linked_document_id 215 | return self.request( 216 | f"/api/parts/d/{escape(did)}/{escape(wmv)}/{escape(wmvid)}/e/{escape(eid)}/partid/{escape(partid)}/massproperties", 217 | query=query, 218 | ) 219 | 220 | @cache_response 221 | def standard_cont_mass_properties( 222 | self, did, vid, eid, partid, linked_document_id, configuration 223 | ): 224 | return self.request( 225 | f"/api/parts/d/{escape(did)}/v/{escape(vid)}/e/{escape(eid)}/partid/{escape(partid)}/massproperties", 226 | query={ 227 | "configuration": configuration, 228 | "useMassPropertyOverrides": True, 229 | "linkDocumentId": linked_document_id, 230 | "inferMetadataOwner": True, 231 | }, 232 | ) 233 | 234 | @cache_response 235 | def elements_configuration( 236 | self, did, wmvid, eid, wmv, linked_document_id=None, configuration=None 237 | ): 238 | query = {} 239 | if linked_document_id is not None: 240 | query["linkDocumentId"] = linked_document_id 241 | return self.request( 242 | f"/api/elements/d/{escape(did)}/{escape(wmv)}/{escape(wmvid)}/e/{escape(eid)}/configuration", 243 | query=query, 244 | ) 245 | 246 | @cache_response 247 | def get_variables(self, did, wvid, eid, wmv, configuration): 248 | return self.request( 249 | f"/api/variables/d/{escape(did)}/{escape(wmv)}/{escape(wvid)}/e/{escape(eid)}/variables", 250 | query={ 251 | "configuration": configuration, 252 | "includeValuesAndReferencedVariables": True, 253 | }, 254 | ) 255 | -------------------------------------------------------------------------------- /onshape_to_robot/onshape_api/onshape.py: -------------------------------------------------------------------------------- 1 | ''' 2 | onshape 3 | ====== 4 | 5 | Provides access to the Onshape REST API 6 | ''' 7 | 8 | from . import utils 9 | 10 | import os 11 | import random 12 | import string 13 | import commentjson as json 14 | import hmac 15 | import hashlib 16 | import base64 17 | import urllib 18 | import datetime 19 | import requests 20 | from colorama import Fore, Back, Style 21 | from urllib.parse import urlparse 22 | from urllib.parse import parse_qs 23 | 24 | __all__ = [ 25 | 'Onshape' 26 | ] 27 | 28 | 29 | class Onshape(): 30 | ''' 31 | Provides access to the Onshape REST API. 32 | 33 | Attributes: 34 | - stack (str): Base URL 35 | - creds (str, default='./creds.json'): Credentials location 36 | - logging (bool, default=True): Turn logging on or off 37 | ''' 38 | 39 | def __init__(self, stack, creds='./config.json', logging=True): 40 | ''' 41 | Instantiates an instance of the Onshape class. Reads credentials from a JSON file 42 | of this format: 43 | 44 | { 45 | "http://cad.onshape.com": { 46 | "access_key": "YOUR KEY HERE", 47 | "secret_key": "YOUR KEY HERE" 48 | }, 49 | etc... add new object for each stack to test on 50 | } 51 | 52 | The creds.json file should be stored in the root project folder; optionally, 53 | you can specify the location of a different file. 54 | 55 | Args: 56 | - stack (str): Base URL 57 | - creds (str, default='./config.json'): Credentials location 58 | ''' 59 | 60 | if not os.path.isfile(creds): 61 | raise IOError('%s is not a file' % creds) 62 | 63 | self._logging = logging 64 | 65 | with open(creds, "r", encoding="utf-8") as stream: 66 | try: 67 | config = json.load(stream) 68 | except TypeError as ex: 69 | raise ValueError('%s is not valid json' % creds) from ex 70 | 71 | try: 72 | self._url = config["onshape_api"] 73 | self._access_key = config['onshape_access_key'].encode('utf-8') 74 | self._secret_key = config['onshape_secret_key'].encode('utf-8') 75 | except KeyError: 76 | self._url = os.getenv('ONSHAPE_API') 77 | self._access_key = os.getenv('ONSHAPE_ACCESS_KEY') 78 | self._secret_key = os.getenv('ONSHAPE_SECRET_KEY') 79 | 80 | if self._url is None or self._access_key is None or self._secret_key is None: 81 | print(Fore.RED + 'ERROR: No Onshape API access key are set' + Style.RESET_ALL) 82 | print() 83 | print(Fore.BLUE + 'TIP: Connect to https://dev-portal.onshape.com/keys, and edit your .bashrc file:' + Style.RESET_ALL) 84 | print(Fore.BLUE + 'export ONSHAPE_API=https://cad.onshape.com' + Style.RESET_ALL) 85 | print(Fore.BLUE + 'export ONSHAPE_ACCESS_KEY=Your_Access_Key' + Style.RESET_ALL) 86 | print(Fore.BLUE + 'export ONSHAPE_SECRET_KEY=Your_Secret_Key' + Style.RESET_ALL) 87 | exit(1) 88 | 89 | self._access_key = self._access_key.encode('utf-8') 90 | self._secret_key = self._secret_key.encode('utf-8') 91 | 92 | if self._url is None or self._access_key is None or self._secret_key is None: 93 | exit('No key in config.json, and environment variables not set') 94 | 95 | if self._logging: 96 | utils.log('onshape instance created: url = %s, access key = %s' % (self._url, self._access_key)) 97 | 98 | def _make_nonce(self): 99 | ''' 100 | Generate a unique ID for the request, 25 chars in length 101 | 102 | Returns: 103 | - str: Cryptographic nonce 104 | ''' 105 | 106 | chars = string.digits + string.ascii_letters 107 | nonce = ''.join(random.choice(chars) for i in range(25)) 108 | 109 | if self._logging: 110 | utils.log('nonce created: %s' % nonce) 111 | 112 | return nonce 113 | 114 | def _make_auth(self, method, date, nonce, path, query={}, ctype='application/json'): 115 | ''' 116 | Create the request signature to authenticate 117 | 118 | Args: 119 | - method (str): HTTP method 120 | - date (str): HTTP date header string 121 | - nonce (str): Cryptographic nonce 122 | - path (str): URL pathname 123 | - query (dict, default={}): URL query string in key-value pairs 124 | - ctype (str, default='application/json'): HTTP Content-Type 125 | ''' 126 | 127 | query = urllib.parse.urlencode(query) 128 | 129 | hmac_str = (method + '\n' + nonce + '\n' + date + '\n' + ctype + '\n' + path + 130 | '\n' + query + '\n').lower().encode('utf-8') 131 | 132 | signature = base64.b64encode(hmac.new(self._secret_key, hmac_str, digestmod=hashlib.sha256).digest()) 133 | auth = 'On ' + self._access_key.decode('utf-8') + ':HmacSHA256:' + signature.decode('utf-8') 134 | 135 | if self._logging: 136 | utils.log({ 137 | 'query': query, 138 | 'hmac_str': hmac_str, 139 | 'signature': signature, 140 | 'auth': auth 141 | }) 142 | 143 | return auth 144 | 145 | def _make_headers(self, method, path, query={}, headers={}): 146 | ''' 147 | Creates a headers object to sign the request 148 | 149 | Args: 150 | - method (str): HTTP method 151 | - path (str): Request path, e.g. /api/documents. No query string 152 | - query (dict, default={}): Query string in key-value format 153 | - headers (dict, default={}): Other headers to pass in 154 | 155 | Returns: 156 | - dict: Dictionary containing all headers 157 | ''' 158 | 159 | date = datetime.datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT') 160 | nonce = self._make_nonce() 161 | ctype = headers.get('Content-Type') if headers.get('Content-Type') else 'application/json' 162 | 163 | auth = self._make_auth(method, date, nonce, path, query=query, ctype=ctype) 164 | 165 | req_headers = { 166 | 'Content-Type': 'application/json', 167 | 'Date': date, 168 | 'On-Nonce': nonce, 169 | 'Authorization': auth, 170 | 'User-Agent': 'Onshape Python Sample App', 171 | 'Accept': 'application/json' 172 | } 173 | 174 | # add in user-defined headers 175 | for h in headers: 176 | req_headers[h] = headers[h] 177 | 178 | return req_headers 179 | 180 | def request(self, method, path, query={}, headers={}, body={}, base_url=None): 181 | ''' 182 | Issues a request to Onshape 183 | 184 | Args: 185 | - method (str): HTTP method 186 | - path (str): Path e.g. /api/documents/:id 187 | - query (dict, default={}): Query params in key-value pairs 188 | - headers (dict, default={}): Key-value pairs of headers 189 | - body (dict, default={}): Body for POST request 190 | - base_url (str, default=None): Host, including scheme and port (if different from creds file) 191 | 192 | Returns: 193 | - requests.Response: Object containing the response from Onshape 194 | ''' 195 | 196 | req_headers = self._make_headers(method, path, query, headers) 197 | if base_url is None: 198 | base_url = self._url 199 | url = base_url + path + '?' + urllib.parse.urlencode(query) 200 | 201 | if self._logging: 202 | utils.log(body) 203 | utils.log(req_headers) 204 | utils.log('request url: ' + url) 205 | 206 | # only parse as json string if we have to 207 | body = json.dumps(body) if type(body) == dict else body 208 | 209 | res = requests.request(method, url, headers=req_headers, data=body, allow_redirects=False, stream=True) 210 | 211 | if res.status_code == 307: 212 | location = urlparse(res.headers["Location"]) 213 | querystring = parse_qs(location.query) 214 | 215 | if self._logging: 216 | utils.log('request redirected to: ' + location.geturl()) 217 | 218 | new_query = {} 219 | new_base_url = location.scheme + '://' + location.netloc 220 | 221 | for key in querystring: 222 | new_query[key] = querystring[key][0] # won't work for repeated query params 223 | 224 | return self.request(method, location.path, query=new_query, headers=headers, base_url=new_base_url) 225 | elif not 200 <= res.status_code <= 206: 226 | print(url) 227 | print('! ERROR ('+str(res.status_code)+') while using Onshape API') 228 | if res.text: 229 | print('! '+res.text) 230 | 231 | if res.status_code == 403: 232 | print('HINT: Check that your access rights are correct, and that the clock on your computer is set correctly') 233 | exit() 234 | if self._logging: 235 | utils.log('request failed, details: ' + res.text, level=1) 236 | else: 237 | if self._logging: 238 | utils.log('request succeeded, details: ' + res.text) 239 | 240 | return res 241 | -------------------------------------------------------------------------------- /onshape_to_robot/onshape_api/utils.py: -------------------------------------------------------------------------------- 1 | ''' 2 | utils 3 | ===== 4 | 5 | Handy functions for API key sample app 6 | ''' 7 | 8 | import logging 9 | from logging.config import dictConfig 10 | 11 | __all__ = [ 12 | 'log' 13 | ] 14 | 15 | 16 | def log(msg, level=0): 17 | ''' 18 | Logs a message to the console, with optional level paramater 19 | 20 | Args: 21 | - msg (str): message to send to console 22 | - level (int): log level; 0 for info, 1 for error (default = 0) 23 | ''' 24 | 25 | red = '\033[91m' 26 | endc = '\033[0m' 27 | 28 | # configure the logging module 29 | cfg = { 30 | 'version': 1, 31 | 'disable_existing_loggers': False, 32 | 'formatters': { 33 | 'stdout': { 34 | 'format': '[%(levelname)s]: %(asctime)s - %(message)s', 35 | 'datefmt': '%x %X' 36 | }, 37 | 'stderr': { 38 | 'format': red + '[%(levelname)s]: %(asctime)s - %(message)s' + endc, 39 | 'datefmt': '%x %X' 40 | } 41 | }, 42 | 'handlers': { 43 | 'stdout': { 44 | 'class': 'logging.StreamHandler', 45 | 'level': 'DEBUG', 46 | 'formatter': 'stdout' 47 | }, 48 | 'stderr': { 49 | 'class': 'logging.StreamHandler', 50 | 'level': 'ERROR', 51 | 'formatter': 'stderr' 52 | } 53 | }, 54 | 'loggers': { 55 | 'info': { 56 | 'handlers': ['stdout'], 57 | 'level': 'INFO', 58 | 'propagate': True 59 | }, 60 | 'error': { 61 | 'handlers': ['stderr'], 62 | 'level': 'ERROR', 63 | 'propagate': False 64 | } 65 | } 66 | } 67 | 68 | dictConfig(cfg) 69 | 70 | lg = 'info' if level == 0 else 'error' 71 | lvl = 20 if level == 0 else 40 72 | 73 | logger = logging.getLogger(lg) 74 | logger.log(lvl, msg) 75 | -------------------------------------------------------------------------------- /onshape_to_robot/processor.py: -------------------------------------------------------------------------------- 1 | from .config import Config 2 | from .robot import Robot 3 | 4 | 5 | class Processor: 6 | def __init__(self, config: Config): 7 | self.config: Config = config 8 | pass 9 | 10 | def process(self, robot: Robot): 11 | pass 12 | -------------------------------------------------------------------------------- /onshape_to_robot/processor_ball_to_euler.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from .processor import Processor 3 | from .config import Config 4 | from .robot import Robot, Link, Joint 5 | from .message import info 6 | import fnmatch 7 | 8 | 9 | class ProcessorBallToEuler(Processor): 10 | """ 11 | Turn ball joints into euler roll/pitch/yaw joints. 12 | """ 13 | 14 | def __init__(self, config: Config): 15 | super().__init__(config) 16 | 17 | # Check if it is enabled in configuration 18 | self.ball_to_euler: bool | list = config.get("ball_to_euler", False) 19 | self.ball_to_euler_order: bool | list = config.get( 20 | "ball_to_euler_order", 21 | "xyz", 22 | values_list=["xyz", "xzy", "zyx", "zxy", "yxz", "yzx"], 23 | ) 24 | 25 | def should_replace(self, joint: Joint) -> bool: 26 | if self.ball_to_euler == True: 27 | return True 28 | elif isinstance(self.ball_to_euler, list): 29 | for entry in self.ball_to_euler: 30 | if fnmatch.fnmatch(entry, joint.name): 31 | return True 32 | return False 33 | 34 | def process(self, robot: Robot): 35 | if self.ball_to_euler: 36 | print(info(f"Replacing balls to euler ({self.ball_to_euler})")) 37 | replaced_joints: list[Joint] = [] 38 | 39 | # Searching for ball joints to replace 40 | for joint in robot.joints: 41 | if joint.joint_type == Joint.BALL and self.should_replace(joint): 42 | parent = joint.parent 43 | children = {} 44 | for letter in self.ball_to_euler_order[:-1]: 45 | body_name = f"{joint.name}_link_{letter}" 46 | new_link = Link(body_name) 47 | children[letter] = new_link 48 | robot.links.append(new_link) 49 | children[self.ball_to_euler_order[-1]] = joint.child 50 | 51 | for letter in self.ball_to_euler_order: 52 | if letter == "x": 53 | axis = np.array([1.0, 0.0, 0.0]) 54 | elif letter == "y": 55 | axis = np.array([0.0, 1.0, 0.0]) 56 | elif letter == "z": 57 | axis = np.array([0.0, 0.0, 1.0]) 58 | 59 | new_joint = Joint( 60 | name=f"{joint.name}_{letter}", 61 | joint_type=Joint.REVOLUTE, 62 | parent=parent, 63 | child=children[letter], 64 | T_world_joint=joint.T_world_joint, 65 | properties=joint.properties.copy(), 66 | axis=axis, 67 | ) 68 | robot.joints.append(new_joint) 69 | parent = children[letter] 70 | 71 | replaced_joints.append(joint) 72 | 73 | # Removing replaced joints 74 | for joint in replaced_joints: 75 | robot.joints.remove(joint) 76 | -------------------------------------------------------------------------------- /onshape_to_robot/processor_collision_as_visual.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from .message import bright, info, error, warning 3 | from .processor import Processor 4 | from .config import Config 5 | from .robot import Robot, Part 6 | from .geometry import Mesh 7 | import numpy as np 8 | 9 | 10 | class ProcessorCollisionAsVisual(Processor): 11 | """ 12 | This processor will update the part mesh and shapes to turn every collision into visual. 13 | 14 | Can be useful for debugging 15 | """ 16 | 17 | def __init__(self, config: Config): 18 | super().__init__(config) 19 | 20 | # OpenSCAD pure shapes 21 | self.collisions_as_visual: bool = config.get("collisions_as_visual", False) 22 | 23 | def process(self, robot: Robot): 24 | """ 25 | Runs the processor 26 | """ 27 | if self.collisions_as_visual: 28 | print(info("+ Converting collisions to visual")) 29 | for link in robot.links: 30 | for part in link.parts: 31 | for mesh in part.meshes: 32 | mesh.visual = mesh.collision 33 | for shape in part.shapes: 34 | shape.visual = shape.collision 35 | part.prune_unused_geometry() 36 | -------------------------------------------------------------------------------- /onshape_to_robot/processor_convex_decomposition.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import os 3 | from pathlib import Path 4 | from .message import bright, info, error, warning 5 | from .processor import Processor 6 | from .config import Config 7 | from .robot import Robot, Part 8 | from .geometry import Mesh 9 | import numpy as np 10 | import pickle 11 | 12 | 13 | class ProcessorConvexDecomposition(Processor): 14 | """ 15 | Convex decomposition processor. Runs CoACD algorithm on collision meshes to use a convex approximation. 16 | """ 17 | 18 | def __init__(self, config: Config): 19 | super().__init__(config) 20 | 21 | # Enable convex decomposition 22 | self.convex_decomposition: bool = config.get("convex_decomposition", False) 23 | self.rainbow_colors: bool = config.get("rainbow_colors", False) 24 | 25 | self.check_coacd() 26 | 27 | def get_cache_path(self) -> Path: 28 | """ 29 | Return the path to the user cache. 30 | """ 31 | path = Path.home() / ".cache" / "onshape-to-robot-convex-decomposition" 32 | path.mkdir(parents=True, exist_ok=True) 33 | return path 34 | 35 | def check_coacd(self): 36 | if self.convex_decomposition: 37 | print(bright("* Checking CoACD presence...")) 38 | try: 39 | import coacd 40 | import trimesh 41 | except ImportError: 42 | print(bright("Can't import CoACD, disabling convex decomposition.")) 43 | print(info("TIP: consider installing CoACD:")) 44 | print(info("pip install coacd trimesh")) 45 | self.convex_decomposition = False 46 | 47 | def process(self, robot: Robot): 48 | if self.convex_decomposition: 49 | os.makedirs(self.config.asset_path("convex_decomposition"), exist_ok=True) 50 | 51 | for link in robot.links: 52 | for part in link.parts: 53 | self.convex_decompose(part) 54 | 55 | def convex_decompose(self, part: Part): 56 | import coacd 57 | import trimesh 58 | 59 | collision_meshes = [mesh for mesh in part.meshes if mesh.collision] 60 | if len(collision_meshes) > 0: 61 | if len(collision_meshes) > 1: 62 | print( 63 | warning( 64 | f"* Skipping convex decomposition for part {part.name} as it already has multiple collision meshes." 65 | ) 66 | ) 67 | 68 | collision_mesh = collision_meshes[0] 69 | 70 | # Retrieving file SHA1 71 | sha1 = hashlib.sha1(open(collision_mesh.filename, "rb").read()).hexdigest() 72 | cache_filename = f"{self.get_cache_path()}/{sha1}.pkl" 73 | 74 | if os.path.exists(cache_filename): 75 | print( 76 | info( 77 | f"* Loading cached CoACD decomposition cache for part {part.name}" 78 | ) 79 | ) 80 | with open(cache_filename, "rb") as f: 81 | meshes = pickle.load(f) 82 | else: 83 | mesh = trimesh.load(collision_mesh.filename, force="mesh") 84 | mesh = coacd.Mesh(mesh.vertices, mesh.faces) 85 | meshes = coacd.run_coacd(mesh, max_convex_hull=16) 86 | with open(cache_filename, "wb") as f: 87 | pickle.dump(meshes, f) 88 | 89 | part.collision_meshes = [] 90 | filename = self.config.asset_path( 91 | f"convex_decomposition/{part.name}_%05d.stl" 92 | ) 93 | for k, mesh in enumerate(meshes): 94 | mesh = trimesh.Trimesh(vertices=mesh[0], faces=mesh[1]) 95 | mesh.export(filename % k) 96 | color = ( 97 | np.random.rand(3) if self.rainbow_colors else collision_mesh.color 98 | ) 99 | part.meshes.append( 100 | Mesh( 101 | filename % k, 102 | color, 103 | visual=False, 104 | collision=True, 105 | ) 106 | ) 107 | part.collision_meshes.append(filename % k) 108 | 109 | collision_mesh.collision = False 110 | part.prune_unused_geometry() 111 | 112 | print( 113 | info(f"* Decomposed part {part.name} into {len(meshes)} convex shapes.") 114 | ) 115 | -------------------------------------------------------------------------------- /onshape_to_robot/processor_dummy_base_link.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from .processor import Processor 3 | from .config import Config 4 | from .robot import Robot, Link, Joint 5 | 6 | 7 | class ProcessorDummyBaseLink(Processor): 8 | """ 9 | Fixed links processor. 10 | When enabled, crawl all the links, and split them into sublinks containing all one part. 11 | """ 12 | 13 | def __init__(self, config: Config): 14 | super().__init__(config) 15 | 16 | # Check if it is enabled in configuration 17 | self.add_dummy_base_link: bool = config.get("add_dummy_base_link", False) 18 | 19 | def process(self, robot: Robot): 20 | if self.add_dummy_base_link: 21 | new_base_link = Link("base_link") 22 | new_base_link.fixed = True 23 | robot.links.append(new_base_link) 24 | 25 | for base_link in robot.base_links: 26 | robot.joints.append( 27 | Joint( 28 | "base_link_to_" + base_link.name, 29 | "fixed", 30 | new_base_link, 31 | base_link, 32 | np.eye(4), 33 | ) 34 | ) 35 | 36 | robot.base_links = [new_base_link] 37 | -------------------------------------------------------------------------------- /onshape_to_robot/processor_fixed_links.py: -------------------------------------------------------------------------------- 1 | from .processor import Processor 2 | from .config import Config 3 | from .robot import Robot, Link, Joint 4 | from .message import info 5 | import fnmatch 6 | 7 | 8 | class ProcessorFixedLinks(Processor): 9 | """ 10 | Fixed links processor. 11 | When enabled, crawl all the links, and split them into sublinks containing all one part. 12 | """ 13 | 14 | def __init__(self, config: Config): 15 | super().__init__(config) 16 | 17 | # Check if it is enabled in configuration 18 | self.use_fixed_links: bool | list = config.get("use_fixed_links", False) 19 | 20 | def should_fix_links(self, link_name: str) -> bool: 21 | if self.use_fixed_links == True: 22 | return True 23 | elif isinstance(self.use_fixed_links, list): 24 | for entry in self.use_fixed_links: 25 | if fnmatch.fnmatch(link_name, entry): 26 | return True 27 | return False 28 | 29 | def process(self, robot: Robot): 30 | if self.use_fixed_links: 31 | print(info(f"Using fixed links ({self.use_fixed_links})")) 32 | new_links = [] 33 | for link in robot.links: 34 | if self.should_fix_links(link.name): 35 | for part in link.parts: 36 | part_link = Link(f"{link.name}_{part.name}") 37 | part_link.parts = [part] 38 | new_links.append([link, part_link]) 39 | link.parts = [] 40 | 41 | for parent_link, new_link in new_links: 42 | robot.links.append(new_link) 43 | robot.joints.append( 44 | Joint( 45 | f"{new_link.name}_fixed", 46 | Joint.FIXED, 47 | parent_link, 48 | new_link, 49 | new_link.parts[0].T_world_part, 50 | ) 51 | ) 52 | -------------------------------------------------------------------------------- /onshape_to_robot/processor_merge_parts.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import os 3 | from .config import Config 4 | from .robot import Robot, Link, Part 5 | from .processor import Processor 6 | from .geometry import Mesh 7 | from .message import bright, info, error 8 | from stl import mesh, Mode 9 | 10 | 11 | class ProcessorMergeParts(Processor): 12 | """ 13 | This processor merge all parts into a single one, combining the STL 14 | """ 15 | 16 | def __init__(self, config: Config): 17 | super().__init__(config) 18 | self.merge_stls = config.get("merge_stls", False) 19 | 20 | def process(self, robot: Robot): 21 | if self.merge_stls: 22 | os.makedirs(self.config.asset_path("merged"), exist_ok=True) 23 | for link in robot.links: 24 | self.merge_parts(link) 25 | 26 | def load_mesh(self, stl_file: str) -> mesh.Mesh: 27 | return mesh.Mesh.from_file(stl_file) 28 | 29 | def save_mesh(self, mesh: mesh.Mesh, stl_file: str): 30 | # Tweaking STL header to avoid timestamp 31 | # This ensures that same process will result in same STL file 32 | def get_header(name): 33 | header = "onshape-to-robot" 34 | return header[:80].ljust(80, " ") 35 | 36 | mesh.get_header = get_header 37 | mesh.save(stl_file, mode=Mode.BINARY) 38 | 39 | def transform_mesh(self, mesh: mesh.Mesh, matrix: np.ndarray): 40 | rotation = matrix[:3, :3] 41 | translation = matrix[:3, 3] 42 | 43 | def transform(points): 44 | return (rotation @ points.T).T + translation 45 | 46 | mesh.v0 = transform(mesh.v0) 47 | mesh.v1 = transform(mesh.v1) 48 | mesh.v2 = transform(mesh.v2) 49 | mesh.normals = transform(mesh.normals) 50 | 51 | def combine_meshes(self, m1: mesh.Mesh, m2: mesh.Mesh): 52 | return mesh.Mesh(np.concatenate([m1.data, m2.data])) 53 | 54 | def merge_parts(self, link: Link): 55 | print(info(f"+ Merging parts for {link.name}")) 56 | 57 | merge_everything = ( 58 | self.merge_stls != "collision" and self.merge_stls != "visual" 59 | ) 60 | 61 | # Computing the frame where the new part will be located at 62 | _, com, __ = link.get_dynamics() 63 | T_world_com = np.eye(4) 64 | T_world_com[:3, 3] = com 65 | 66 | # Computing a new color, weighting by masses 67 | color = np.zeros(3) 68 | total_mass = 0 69 | for part in link.parts: 70 | if len(part.meshes): 71 | meshes_color = np.mean([mesh.color for mesh in part.meshes], axis=0) 72 | color += meshes_color * part.mass 73 | total_mass += part.mass 74 | 75 | color /= total_mass 76 | 77 | # Changing shapes frame 78 | merged_shapes = [] 79 | for part in link.parts: 80 | if part.shapes is not None: 81 | for shape in part.shapes: 82 | if merge_everything or shape.is_type(self.merge_stls): 83 | # Changing the shape frame 84 | T_world_shape = part.T_world_part @ shape.T_part_shape 85 | shape.T_part_shape = np.linalg.inv(T_world_com) @ T_world_shape 86 | merged_shapes.append(shape) 87 | 88 | # Merging STL files 89 | def accumulate_meshes(which: str): 90 | mesh = None 91 | for part in link.parts: 92 | for part_mesh in part.meshes: 93 | if part_mesh.is_type(which): 94 | if which == "visual": 95 | part_mesh.visual = False 96 | else: 97 | part_mesh.collision = False 98 | 99 | # Retrieving meshes 100 | part_mesh = self.load_mesh(part_mesh.filename) 101 | 102 | # Expressing meshes in the merged frame 103 | T_com_part = np.linalg.inv(T_world_com) @ part.T_world_part 104 | self.transform_mesh(part_mesh, T_com_part) 105 | 106 | if mesh is None: 107 | mesh = part_mesh 108 | else: 109 | mesh = self.combine_meshes(mesh, part_mesh) 110 | return mesh 111 | 112 | merged_meshes = [] 113 | 114 | if self.merge_stls != "collision": 115 | visual_mesh = accumulate_meshes("visual") 116 | if visual_mesh is not None: 117 | filename = self.config.asset_path( 118 | "merged/" + "/" + link.name + "_visual.stl" 119 | ) 120 | self.save_mesh(visual_mesh, filename) 121 | merged_meshes.append( 122 | Mesh(filename, color, visual=True, collision=False) 123 | ) 124 | 125 | if self.merge_stls != "visual": 126 | collision_mesh = accumulate_meshes("collision") 127 | if collision_mesh is not None: 128 | filename = self.config.asset_path( 129 | "merged/" + "/" + link.name + "_collision.stl" 130 | ) 131 | self.save_mesh(collision_mesh, filename) 132 | merged_meshes.append( 133 | Mesh(filename, color, visual=False, collision=True) 134 | ) 135 | 136 | mass, com, inertia = link.get_dynamics(T_world_com) 137 | if merge_everything: 138 | # Remove all parts 139 | link.parts = [] 140 | else: 141 | # We keep the existing parts and add a massless part with merged meshes 142 | mass = 0 143 | inertia *= 0 144 | 145 | # Replacing parts with a single one 146 | link.parts.append( 147 | Part( 148 | f"{link.name}_parts", 149 | T_world_com, 150 | mass, 151 | com, 152 | inertia, 153 | merged_meshes, 154 | merged_shapes, 155 | ) 156 | ) 157 | -------------------------------------------------------------------------------- /onshape_to_robot/processor_no_collision_meshes.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from .message import bright, info, error, warning 3 | from .processor import Processor 4 | from .config import Config 5 | from .robot import Robot, Part 6 | 7 | 8 | class ProcessorNoCollisionMeshes(Processor): 9 | """ 10 | This processor ensures no collision meshes are present in the robot 11 | """ 12 | 13 | def __init__(self, config: Config): 14 | super().__init__(config) 15 | 16 | # OpenSCAD pure shapes 17 | self.no_collision_meshes: bool = config.get("no_collision_meshes", False) 18 | 19 | def process(self, robot: Robot): 20 | """ 21 | Runs the processor 22 | """ 23 | if self.no_collision_meshes: 24 | print(info("+ Removing collision meshes")) 25 | for link in robot.links: 26 | for part in link.parts: 27 | for mesh in part.meshes: 28 | mesh.collision = False 29 | part.prune_unused_geometry() 30 | -------------------------------------------------------------------------------- /onshape_to_robot/processor_scad.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import re 3 | import json 4 | import os 5 | from .message import bright, info, error 6 | from .processor import Processor 7 | from .config import Config 8 | from .robot import Robot 9 | from .geometry import Box, Cylinder, Sphere, Shape 10 | import numpy as np 11 | 12 | 13 | class ProcessorScad(Processor): 14 | """ 15 | Scad processor. This processor will parse OpenSCAD files to create pure shapes when available. 16 | 17 | The code is a naive parser of the intermediate CSG file produced by OpenSCAD, gathering Box, Sphere and Cylinders. 18 | """ 19 | 20 | def __init__(self, config: Config): 21 | super().__init__(config) 22 | 23 | # OpenSCAD pure shapes 24 | self.use_scads: bool = config.get("use_scads", False) 25 | self.pure_shape_dilatation: float = config.get("pure_shape_dilatation", 0.0) 26 | 27 | if self.use_scads: 28 | self.check_openscad() 29 | 30 | def check_openscad(self): 31 | if self.use_scads: 32 | print(bright("* Checking OpenSCAD presence...")) 33 | try: 34 | subprocess.run(["openscad", "-v"]) 35 | except FileNotFoundError: 36 | print(bright("Can't run openscad -v, disabling OpenSCAD support")) 37 | print(info("TIP: consider installing openscad:")) 38 | print(info("Linux:")) 39 | print(info("sudo add-apt-repository ppa:openscad/releases")) 40 | print(info("sudo apt-get update")) 41 | print(info("sudo apt-get install openscad")) 42 | print(info("Windows:")) 43 | print(info("go to: https://openscad.org/downloads.html ")) 44 | self.use_scads = False 45 | 46 | def process(self, robot: Robot): 47 | if self.use_scads: 48 | print(info("+ Parsing OpenSCAD files...")) 49 | for link in robot.links: 50 | for part in link.parts: 51 | converted_meshes = [] 52 | for mesh in part.meshes: 53 | if mesh.collision: 54 | scad_file = mesh.filename.replace(".stl", ".scad") 55 | if os.path.exists(scad_file): 56 | part.shapes += self.parse_scad(scad_file, mesh.color) 57 | converted_meshes.append(mesh) 58 | 59 | for converted_mesh in converted_meshes: 60 | converted_mesh.collision = False 61 | part.prune_unused_geometry() 62 | 63 | def multmatrix_parse(self, parameters: str): 64 | matrix = np.matrix(json.loads(parameters), dtype=float) 65 | matrix[0, 3] /= 1000.0 66 | matrix[1, 3] /= 1000.0 67 | matrix[2, 3] /= 1000.0 68 | 69 | return matrix 70 | 71 | def cube_parse(self, parameters: str): 72 | results = re.findall(r"^size = (.+), center = (.+)$", parameters) 73 | if len(results) != 1: 74 | raise Exception(f"! Can't parse CSG cube parameters: {parameters}") 75 | extra = np.array([self.pure_shape_dilatation] * 3) 76 | 77 | return ( 78 | extra + np.array(json.loads(results[0][0]), dtype=float) / 1000.0 79 | ), results[0][1] == "true" 80 | 81 | def cylinder_parse(self, parameters: str): 82 | results = re.findall( 83 | r"h = (.+), r1 = (.+), r2 = (.+), center = (.+)", parameters 84 | ) 85 | if len(results) != 1: 86 | raise Exception(f"! Can't parse CSG cylinder parameters: {parameters}") 87 | result = results[0] 88 | extra = np.array([self.pure_shape_dilatation / 2, self.pure_shape_dilatation]) 89 | 90 | return (extra + np.array([result[0], result[1]], dtype=float) / 1000.0), result[ 91 | 3 92 | ] == "true" 93 | 94 | def sphere_parse(self, parameters: str): 95 | results = re.findall(r"r = (.+)$", parameters) 96 | if len(results) != 1: 97 | raise Exception(f"! Can't parse CSG sphere parameters: {parameters}") 98 | 99 | return self.pure_shape_dilatation + float(results[0]) / 1000.0 100 | 101 | def extract_node_parameters(self, line: str): 102 | line = line.strip() 103 | parts = line.split("(", 1) 104 | node = parts[0] 105 | parameters = parts[1] 106 | if parameters[-1] == ";": 107 | parameters = parameters[:-2] 108 | if parameters[-1] == "{": 109 | parameters = parameters[:-3] 110 | return node, parameters 111 | 112 | def translation(self, x: float, y: float, z: float): 113 | m = np.eye(4) 114 | m[:3, 3] = [x, y, z] 115 | 116 | return m 117 | 118 | def parse_csg(self, csg_data: str, color): 119 | shapes: list[Shape] = [] 120 | lines = csg_data.split("\n") 121 | matrices = [] 122 | 123 | for line in lines: 124 | line = line.strip() 125 | if line != "": 126 | if line[-1] == "{": 127 | node, parameters = self.extract_node_parameters(line) 128 | if node == "multmatrix": 129 | matrix = self.multmatrix_parse(parameters) 130 | else: 131 | matrix = np.eye(4) 132 | matrices.append(matrix) 133 | elif line[-1] == "}": 134 | matrices.pop() 135 | else: 136 | node, parameters = self.extract_node_parameters(line) 137 | 138 | transform = np.eye(4) 139 | for matrix in matrices: 140 | transform = transform @ matrix 141 | 142 | if node == "cube": 143 | size, center = self.cube_parse(parameters) 144 | if not center: 145 | transform = transform @ self.translation( 146 | size[0] / 2.0, size[1] / 2.0, size[2] / 2.0 147 | ) 148 | shapes.append( 149 | Box(transform, size, color, visual=False, collision=True) 150 | ) 151 | if node == "cylinder": 152 | size, center = self.cylinder_parse(parameters) 153 | if not center: 154 | transform = transform @ self.translation( 155 | 0, 0, size[0] / 2.0 156 | ) 157 | shapes.append( 158 | Cylinder( 159 | transform, 160 | size[0], 161 | size[1], 162 | color, 163 | visual=False, 164 | collision=True, 165 | ) 166 | ) 167 | if node == "sphere": 168 | shapes.append( 169 | Sphere( 170 | transform, 171 | self.sphere_parse(parameters), 172 | color, 173 | visual=False, 174 | collision=True, 175 | ) 176 | ) 177 | 178 | return shapes 179 | 180 | def parse_scad(self, scad_file: str, color: np.ndarray): 181 | tmp_data = os.getcwd() + "/_tmp_data.csg" 182 | os.system("openscad " + scad_file + " -o " + tmp_data) 183 | with open(tmp_data, "r", encoding="utf-8") as stream: 184 | data = stream.read() 185 | os.system("rm " + tmp_data) 186 | 187 | return self.parse_csg(data, color) 188 | -------------------------------------------------------------------------------- /onshape_to_robot/processor_simplify_stls.py: -------------------------------------------------------------------------------- 1 | import os 2 | from .config import Config 3 | from .robot import Robot 4 | from .processor import Processor 5 | from .message import bright, info, error 6 | 7 | 8 | class ProcessorSimplifySTLs(Processor): 9 | """ 10 | Allow for mesh simplifications using MeshLab 11 | """ 12 | 13 | def __init__(self, config: Config): 14 | super().__init__(config) 15 | 16 | # STL merge / simplification 17 | self.simplify_stls = config.get("simplify_stls", False) 18 | self.max_stl_size = config.get("max_stl_size", 3) 19 | 20 | if self.simplify_stls: 21 | self.pymeshlab = self.check_meshlab() 22 | 23 | def check_meshlab(self): 24 | print(bright("* Checking pymeshlab presence...")) 25 | try: 26 | import pymeshlab 27 | 28 | return pymeshlab 29 | except ImportError: 30 | self.simplify_stls = False 31 | print(error("No pymeshlab, disabling STL simplification support")) 32 | print(info("TIP: consider installing pymeshlab:")) 33 | print(info("pip install pymeshlab")) 34 | 35 | def process(self, robot: Robot): 36 | if self.simplify_stls: 37 | simplify_all = ( 38 | self.simplify_stls != "vision" and self.simplify_stls != "collision" 39 | ) 40 | simplified = set() 41 | for link in robot.links: 42 | for part in link.parts: 43 | for mesh in part.meshes: 44 | if ( 45 | simplify_all or mesh.is_type(self.simplify_stls) 46 | ) and mesh.filename not in simplified: 47 | simplified.add(mesh.filename) 48 | self.simplify_stl(mesh.filename) 49 | 50 | def reduce_faces(self, filename: str, reduction: float = 0.9): 51 | mesh_set = self.pymeshlab.MeshSet() 52 | 53 | # Add input mesh 54 | mesh_set.load_new_mesh(filename) 55 | 56 | # Apply filter 57 | mesh_set.apply_filter( 58 | "meshing_decimation_quadric_edge_collapse", 59 | targetperc=reduction, 60 | qualitythr=0.5, 61 | preserveboundary=False, 62 | boundaryweight=1, 63 | preservenormal=True, 64 | preservetopology=False, 65 | optimalplacement=True, 66 | planarquadric=True, 67 | qualityweight=False, 68 | planarweight=0.001, 69 | autoclean=True, 70 | selected=False, 71 | ) 72 | 73 | # Save mesh 74 | mesh_set.save_current_mesh(filename) 75 | 76 | def simplify_stl(self, filename: str): 77 | size_M = os.path.getsize(filename) / (1024 * 1024) 78 | 79 | if size_M > self.max_stl_size: 80 | print( 81 | info( 82 | f"+ {os.path.basename(filename)} is {size_M:.2f} M, running mesh simplification" 83 | ) 84 | ) 85 | self.reduce_faces(filename, self.max_stl_size / size_M) 86 | -------------------------------------------------------------------------------- /onshape_to_robot/processors.py: -------------------------------------------------------------------------------- 1 | from .processor_merge_parts import ProcessorMergeParts 2 | from .processor_scad import ProcessorScad 3 | from .processor_simplify_stls import ProcessorSimplifySTLs 4 | from .processor_fixed_links import ProcessorFixedLinks 5 | from .processor_dummy_base_link import ProcessorDummyBaseLink 6 | from .processor_convex_decomposition import ProcessorConvexDecomposition 7 | from .processor_collision_as_visual import ProcessorCollisionAsVisual 8 | from .processor_no_collision_meshes import ProcessorNoCollisionMeshes 9 | from .processor_ball_to_euler import ProcessorBallToEuler 10 | 11 | default_processors = [ 12 | ProcessorBallToEuler, 13 | ProcessorScad, 14 | ProcessorMergeParts, 15 | ProcessorSimplifySTLs, 16 | ProcessorFixedLinks, 17 | ProcessorDummyBaseLink, 18 | ProcessorConvexDecomposition, 19 | ProcessorNoCollisionMeshes, 20 | ProcessorCollisionAsVisual, 21 | ] 22 | -------------------------------------------------------------------------------- /onshape_to_robot/pure_sketch.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | import numpy as np 3 | import math 4 | import commentjson as json 5 | import os 6 | import sys, os 7 | from dotenv import load_dotenv, find_dotenv 8 | from colorama import Fore, Back, Style 9 | 10 | load_dotenv(find_dotenv(usecwd=True)) 11 | 12 | if len(sys.argv) < 2: 13 | print("Usage: onshape-to-robot-pure-shape {STL file} [prefix=PureShapes]") 14 | else: 15 | fileName = sys.argv[1] 16 | robotDir = os.path.dirname(fileName) 17 | configFile = os.path.join(robotDir, "config.json") 18 | prefix = "PureShapes" 19 | if len(sys.argv) > 2: 20 | prefix = sys.argv[2] 21 | 22 | from .onshape_api.client import Client 23 | 24 | client = Client(logging=False, creds=configFile) 25 | 26 | parts = fileName.split(".") 27 | parts[-1] = "part" 28 | partFileName = ".".join(parts) 29 | parts[-1] = "scad" 30 | scadFileName = ".".join(parts) 31 | 32 | with open(partFileName, "r", encoding="utf-8") as stream: 33 | part = json.load(stream) 34 | partid = part["partId"] 35 | result = client.get_sketches( 36 | part["documentId"], 37 | part["documentMicroversion"], 38 | part["elementId"], 39 | part["configuration"], 40 | ) 41 | 42 | scad = '% scale(1000) import("' + os.path.basename(fileName) + '");\n' 43 | 44 | sketchDatas = [] 45 | for sketch in result["sketches"]: 46 | if sketch["sketch"].startswith(prefix): 47 | parts = sketch["sketch"].split(" ") 48 | if len(parts) >= 2: 49 | sketch["thickness"] = float(parts[1]) 50 | else: 51 | print( 52 | Fore.RED 53 | + 'ERROR: The sketch name should contain extrusion size (e.g "PureShapes 5.3")' 54 | + Style.RESET_ALL 55 | ) 56 | exit(0) 57 | sketchDatas.append(sketch) 58 | 59 | if len(sketchDatas): 60 | print( 61 | Fore.GREEN 62 | + "* Found " 63 | + str(len(sketchDatas)) 64 | + " PureShapes sketches" 65 | + Style.RESET_ALL 66 | ) 67 | for sketchData in sketchDatas: 68 | # Retrieving sketch transform matrix 69 | m = sketchData["transformMatrix"] 70 | mm = [m[0:4], m[4:8], m[8:12], m[12:16]] 71 | mm[0][3] *= 1000 72 | mm[1][3] *= 1000 73 | mm[2][3] *= 1000 74 | scad += "\n" 75 | scad += "// Sketch " + sketchData["sketch"] + "\n" 76 | scad += "multmatrix(" + str(mm) + ") {" + "\n" 77 | scad += "thickness = %f;\n" % sketchData["thickness"] 78 | scad += "translate([0, 0, -thickness]) {\n" 79 | 80 | boxes = {} 81 | 82 | def boxSet(id, pointName, point): 83 | if id not in boxes: 84 | boxes[id] = {} 85 | boxes[id][pointName] = point 86 | 87 | for entry in sketchData["geomEntities"]: 88 | if entry["entityType"] == "circle": 89 | center = entry["center"] 90 | scad += " translate([%f, %f, 0]) {\n" % ( 91 | center[0] * 1000, 92 | center[1] * 1000, 93 | ) 94 | scad += " cylinder(r=%f,h=thickness);\n" % ( 95 | entry["radius"] * 1000 96 | ) 97 | scad += " }\n" 98 | if entry["entityType"] == "point": 99 | parts = entry["id"].split(".") 100 | if len(parts) == 3: 101 | if parts[1] == "top" and parts[2] == "start": 102 | boxSet(parts[0], "A", entry["point"]) 103 | if parts[1] == "top" and parts[2] == "end": 104 | boxSet(parts[0], "B", entry["point"]) 105 | if parts[1] == "bottom" and parts[2] == "start": 106 | boxSet(parts[0], "C", entry["point"]) 107 | if parts[1] == "bottom" and parts[2] == "end": 108 | boxSet(parts[0], "D", entry["point"]) 109 | 110 | for id in boxes: 111 | if len(boxes[id]) == 4: 112 | A, B = np.array(boxes[id]["A"]), np.array(boxes[id]["B"]) 113 | C, D = np.array(boxes[id]["C"]), np.array(boxes[id]["D"]) 114 | AB = B - A 115 | 116 | # Making sure that the orientation of the square is correct 117 | AB90 = np.array([-AB[1], AB[0]]) 118 | side = AB90.dot(C - A) 119 | width = np.linalg.norm(B - A) 120 | height = np.linalg.norm(B - D) 121 | if side < 0: 122 | A, B, C, D = C, D, A, B 123 | 124 | AB = B - A 125 | alpha = np.rad2deg(math.atan2(AB[1], AB[0])) 126 | scad += " translate([%f, %f, 0]) {\n" % ( 127 | A[0] * 1000, 128 | A[1] * 1000, 129 | ) 130 | scad += " rotate([0, 0, " + str(alpha) + "]) {" + "\n" 131 | scad += " cube([%f, %f, thickness]);\n" % ( 132 | width * 1000, 133 | height * 1000, 134 | ) 135 | scad += " }\n" 136 | scad += " }\n" 137 | 138 | scad += "}\n" 139 | scad += "}\n" 140 | 141 | with open(scadFileName, "w", encoding="utf-8") as stream: 142 | stream.write(scad) 143 | 144 | directory = os.path.dirname(fileName) 145 | os.system( 146 | "cd " + directory + "; openscad " + os.path.basename(scadFileName) 147 | ) 148 | else: 149 | print( 150 | Fore.RED 151 | + "ERROR: Can't find pure shape sketch in this part" 152 | + Style.RESET_ALL 153 | ) 154 | 155 | 156 | if __name__ == "__main__": 157 | main() 158 | -------------------------------------------------------------------------------- /onshape_to_robot/robot.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from copy import deepcopy 3 | import numpy as np 4 | from .geometry import Shape, Mesh 5 | 6 | 7 | class Part: 8 | """ 9 | A part is a single component of a link. 10 | """ 11 | 12 | def __init__( 13 | self, 14 | name: str, 15 | T_world_part: np.ndarray, 16 | mass: float, 17 | com: np.ndarray, 18 | inertia: np.ndarray, 19 | meshes: list[Mesh] = [], 20 | shapes: list[Shape] = [], 21 | ): 22 | self.name: str = name 23 | self.T_world_part: np.ndarray = T_world_part 24 | self.mass: float = mass 25 | self.com: np.ndarray = com 26 | self.inertia: np.ndarray = inertia 27 | self.meshes: list[Mesh] = deepcopy(meshes) 28 | self.shapes: list[Shape] = deepcopy(shapes) 29 | 30 | def prune_unused_geometry(self): 31 | """ 32 | Remove meshes or shapes that are neither visual nor collision. 33 | """ 34 | self.meshes = [mesh for mesh in self.meshes if (mesh.visual or mesh.collision)] 35 | self.shapes = [ 36 | shape for shape in self.shapes if (shape.visual or shape.collision) 37 | ] 38 | 39 | 40 | class Link: 41 | """ 42 | A link of a robot. 43 | """ 44 | 45 | def __init__(self, name: str): 46 | self.name = name 47 | self.parts: list[Part] = [] 48 | self.frames: dict[str, np.ndarray] = {} 49 | self.fixed: bool = False 50 | 51 | def get_dynamics(self, T_world_frame: np.ndarray = np.eye(4)): 52 | """ 53 | Returns the dynamics (mass, com, inertia) in a given frame. 54 | The CoM is expressed in the required frame. 55 | Inertia is expressed around the CoM, aligned with the required frame. 56 | """ 57 | mass = 0 58 | com = np.zeros(3) 59 | inertia = np.zeros((3, 3)) 60 | T_frame_world = np.linalg.inv(T_world_frame) 61 | 62 | for part in self.parts: 63 | T_frame_part = T_frame_world @ part.T_world_part 64 | com_frame = (T_frame_part @ [*part.com, 1])[:3] 65 | com += com_frame * part.mass 66 | mass += part.mass 67 | 68 | if mass > 1e-9: 69 | com /= mass 70 | 71 | for part in self.parts: 72 | T_frame_part = T_frame_world @ part.T_world_part 73 | com_frame = (T_frame_part @ [*part.com, 1])[:3] 74 | R = T_frame_part[:3, :3] 75 | q = (com_frame - com).reshape((3, 1)) 76 | # See Modern Robotics, (8.26) & (8.27) 77 | inertia += ( 78 | R @ part.inertia @ R.T + ((q.T @ q) * np.eye(3) - q @ q.T) * part.mass 79 | ) 80 | 81 | return mass, com, inertia 82 | 83 | 84 | class Relation: 85 | """ 86 | Represents a relation (for example a gear) with a source joint 87 | """ 88 | 89 | def __init__(self, source_joint: str, ratio: float): 90 | self.source_joint: str = source_joint 91 | self.ratio: float = ratio 92 | 93 | 94 | class Joint: 95 | """ 96 | A joint connects two links. 97 | """ 98 | 99 | # Joint types 100 | FIXED = "fixed" 101 | REVOLUTE = "revolute" 102 | PRISMATIC = "prismatic" 103 | CONTINUOUS = "continuous" 104 | BALL = "ball" 105 | 106 | def __init__( 107 | self, 108 | name: str, 109 | joint_type: str, 110 | parent: Link, 111 | child: Link, 112 | T_world_joint: np.ndarray, 113 | properties: dict = {}, 114 | limits: tuple[float, float] | None = None, 115 | axis: np.ndarray = np.array([0.0, 0.0, 1.0]), 116 | ): 117 | self.name: str = name 118 | self.joint_type: str = joint_type 119 | self.properties: dict = properties 120 | self.parent: Link = parent 121 | self.child: Link = child 122 | self.limits: tuple[float, float] | None = limits 123 | self.axis: np.ndarray = axis 124 | self.T_world_joint: np.ndarray = T_world_joint 125 | self.relation: Relation | None = None 126 | 127 | 128 | class Closure: 129 | """ 130 | A kinematics closure 131 | """ 132 | 133 | FIXED = "fixed" 134 | REVOLUTE = "revolute" 135 | BALL = "ball" 136 | SLIDER = "slider" 137 | 138 | def __init__(self, closure_type: str, frame1: str, frame2: str): 139 | self.closure_type: str = closure_type 140 | self.frame1: str = frame1 141 | self.frame2: str = frame2 142 | 143 | 144 | class Robot: 145 | """ 146 | Robot representation produced after requesting Onshape API, and before 147 | exporting (e.g URDF, MuJoCo). 148 | """ 149 | 150 | def __init__(self, name: str): 151 | self.name: str = name 152 | self.links: list[Link] = [] 153 | self.base_links: list[Link] = [] 154 | self.joints: list[Joint] = [] 155 | self.closures: list[Closure] = [] 156 | 157 | def get_link(self, name: str): 158 | for link in self.links: 159 | if link.name == name: 160 | return link 161 | raise ValueError(f"Link {name} not found") 162 | 163 | def get_joint(self, name: str): 164 | for joint in self.joints: 165 | if joint.name == name: 166 | return joint 167 | raise ValueError(f"Joint {name} not found") 168 | 169 | def get_link_joints(self, link: Link): 170 | return [joint for joint in self.joints if joint.parent == link] 171 | -------------------------------------------------------------------------------- /onshape_to_robot/robot_builder.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import os 3 | import hashlib 4 | import json 5 | import fnmatch 6 | from .geometry import Mesh 7 | from .message import warning, info, success, error, dim, bright 8 | from .assembly import Assembly 9 | from .config import Config 10 | from .robot import Part, Joint, Link, Robot, Relation, Closure 11 | from .csg import process as csg_process 12 | 13 | 14 | class RobotBuilder: 15 | def __init__(self, config: Config): 16 | self.config: Config = config 17 | self.assembly: Assembly = Assembly(config) 18 | self.robot: Robot = Robot(config.robot_name) 19 | 20 | for closure_type, frame1, frame2 in self.assembly.closures: 21 | self.robot.closures.append(Closure(closure_type, frame1, frame2)) 22 | 23 | self.unique_names = {} 24 | self.stl_filenames: dict = {} 25 | 26 | for node in self.assembly.root_nodes: 27 | link = self.build_robot(node) 28 | self.robot.base_links.append(link) 29 | 30 | def part_is_ignored(self, name: str, what: str) -> bool: 31 | """ 32 | Checks if a given part should be ignored by config 33 | """ 34 | ignored = False 35 | 36 | # Removing <1>, <2> etc. suffix 37 | name = "<".join(name.split("<")[:-1]).strip() 38 | 39 | for entry in self.config.ignore: 40 | to_ignore = True 41 | match_entry = entry 42 | if entry[0] == "!": 43 | to_ignore = False 44 | match_entry = entry[1:] 45 | 46 | if fnmatch.fnmatch(name.lower(), match_entry.lower()): 47 | if ( 48 | self.config.ignore[entry] == "all" 49 | or self.config.ignore[entry] == what 50 | ): 51 | ignored = to_ignore 52 | 53 | return ignored 54 | 55 | def slugify(self, value: str) -> str: 56 | """ 57 | Turns a value into a slug 58 | """ 59 | return "".join(c if c.isalnum() else "_" for c in value).strip("_") 60 | 61 | def printable_configuration(self, instance: dict) -> str: 62 | """ 63 | Retrieve configuration enums to replace "List_..." with proper enum names 64 | """ 65 | configuration = instance["configuration"] 66 | 67 | if instance["configuration"] != "default": 68 | if "documentVersion" in instance: 69 | version = instance["documentVersion"] 70 | wmv = "v" 71 | else: 72 | version = instance["documentMicroversion"] 73 | wmv = "m" 74 | elements = self.assembly.client.elements_configuration( 75 | instance["documentId"], 76 | version, 77 | instance["elementId"], 78 | wmv=wmv, 79 | linked_document_id=self.config.document_id, 80 | ) 81 | for entry in elements["configurationParameters"]: 82 | type_name = entry["typeName"] 83 | message = entry["message"] 84 | 85 | if type_name.startswith("BTMConfigurationParameterEnum"): 86 | parameter_name = message["parameterName"] 87 | parameter_id = message["parameterId"] 88 | configuration = configuration.replace(parameter_id, parameter_name) 89 | 90 | return configuration 91 | 92 | def part_name(self, part: dict, include_configuration: bool = False) -> str: 93 | """ 94 | Retrieve the name from a part. 95 | i.e "Base link <1>" -> "base_link" 96 | """ 97 | name = part["name"] 98 | parts = name.split(" ") 99 | del parts[-1] 100 | base_part_name = self.slugify("_".join(parts).lower()) 101 | 102 | if not include_configuration: 103 | return base_part_name 104 | 105 | # Only add configuration to name if its not default and not a very long configuration (which happens for library parts like screws) 106 | configuration = self.printable_configuration(part) 107 | if configuration != "default" and self.config.include_configuration_suffix: 108 | if len(configuration) < 40: 109 | parts += ["_" + configuration.replace("=", "_").replace(" ", "_")] 110 | else: 111 | parts += ["_" + hashlib.md5(configuration.encode("utf-8")).hexdigest()] 112 | 113 | return self.slugify("_".join(parts).lower()) 114 | 115 | def unique_name(self, part: dict, type: str): 116 | """ 117 | Get unique part name (plate, plate_2, plate_3, ...) 118 | In the case where multiple parts have the same name in Onshape, they will result in different names in the URDF 119 | """ 120 | while True: 121 | name = self.part_name(part, include_configuration=True) 122 | 123 | if type not in self.unique_names: 124 | self.unique_names[type] = {} 125 | 126 | if name in self.unique_names[type]: 127 | self.unique_names[type][name] += 1 128 | name = f"{name}_{self.unique_names[type][name]}" 129 | else: 130 | self.unique_names[type][name] = 1 131 | name = name 132 | 133 | if name not in [frame.name for frame in self.assembly.frames]: 134 | return name 135 | 136 | def instance_request_params(self, instance: dict) -> dict: 137 | """ 138 | Build parameters to make an API call for a given instance 139 | """ 140 | params = {} 141 | 142 | if "documentVersion" in instance: 143 | params["wmvid"] = instance["documentVersion"] 144 | params["wmv"] = "v" 145 | else: 146 | params["wmvid"] = instance["documentMicroversion"] 147 | params["wmv"] = "m" 148 | 149 | params["did"] = instance["documentId"] 150 | params["eid"] = instance["elementId"] 151 | params["linked_document_id"] = self.config.document_id 152 | params["configuration"] = instance["configuration"] 153 | 154 | return params 155 | 156 | def get_stl_filename(self, instance: dict) -> str: 157 | """ 158 | Get a STL filename unique to the instance 159 | """ 160 | exact_instance = ( 161 | instance["documentId"], 162 | instance["documentMicroversion"], 163 | instance["elementId"], 164 | instance["configuration"], 165 | instance["partId"], 166 | ) 167 | 168 | if exact_instance not in self.stl_filenames: 169 | part_name_config = self.part_name(instance, True) 170 | stl_filename = part_name_config 171 | k = 1 172 | while stl_filename in self.stl_filenames.values(): 173 | k += 1 174 | stl_filename = part_name_config + f"__{k}" 175 | if k != 1: 176 | print( 177 | warning( 178 | f'WARNING: Parts with same name "{part_name_config}", incrementing STL name to "{stl_filename}"' 179 | ) 180 | ) 181 | self.stl_filenames[exact_instance] = stl_filename 182 | 183 | return self.stl_filenames[exact_instance] 184 | 185 | def get_stl(self, instance: dict) -> str: 186 | """ 187 | Download and store STL file 188 | """ 189 | os.makedirs(self.config.asset_path(""), exist_ok=True) 190 | 191 | stl_filename = self.get_stl_filename(instance) 192 | filename = stl_filename + ".stl" 193 | 194 | params = self.instance_request_params(instance) 195 | stl = self.assembly.client.part_studio_stl_m( 196 | **params, 197 | partid=instance["partId"], 198 | ) 199 | with open(self.config.asset_path(filename), "wb") as stream: 200 | stream.write(stl) 201 | 202 | # Storing metadata for imported instances in the .part file 203 | stl_metadata = stl_filename + ".part" 204 | with open( 205 | self.config.asset_path(stl_metadata), "w", encoding="utf-8" 206 | ) as stream: 207 | json.dump(instance, stream, indent=4, sort_keys=True) 208 | 209 | return self.config.asset_path(filename) 210 | 211 | def get_color(self, instance: dict) -> np.ndarray: 212 | """ 213 | Retrieve the color of a part 214 | """ 215 | if self.config.color is not None: 216 | color = np.array(self.config.color) 217 | else: 218 | params = self.instance_request_params(instance) 219 | metadata = self.assembly.client.part_get_metadata( 220 | **params, 221 | partid=instance["partId"], 222 | ) 223 | 224 | color = np.array([0.5, 0.5, 0.5]) 225 | 226 | # XXX: There must be a better way to retrieve the part color 227 | for entry in metadata["properties"]: 228 | if ( 229 | "value" in entry 230 | and type(entry["value"]) is dict 231 | and "color" in entry["value"] 232 | ): 233 | rgb = entry["value"]["color"] 234 | color = np.array([rgb["red"], rgb["green"], rgb["blue"]]) / 255.0 235 | 236 | return color 237 | 238 | def get_dynamics(self, instance: dict) -> tuple: 239 | """ 240 | Retrieve the dynamics (mass, com, inertia) of a given instance 241 | """ 242 | if self.config.no_dynamics: 243 | mass = 0 244 | com = [0] * 3 245 | inertia = [0] * 12 246 | else: 247 | if instance["isStandardContent"]: 248 | mass_properties = self.assembly.client.standard_cont_mass_properties( 249 | instance["documentId"], 250 | instance["documentVersion"], 251 | instance["elementId"], 252 | instance["partId"], 253 | configuration=instance["configuration"], 254 | linked_document_id=self.config.document_id, 255 | ) 256 | else: 257 | params = self.instance_request_params(instance) 258 | mass_properties = self.assembly.client.part_mass_properties( 259 | **params, 260 | partid=instance["partId"], 261 | ) 262 | 263 | if instance["partId"] not in mass_properties["bodies"]: 264 | print( 265 | warning( 266 | f"WARNING: part {instance['name']} has no dynamics (maybe it is a surface)" 267 | ) 268 | ) 269 | return 270 | mass_properties = mass_properties["bodies"][instance["partId"]] 271 | mass = mass_properties["mass"][0] 272 | com = mass_properties["centroid"] 273 | inertia = mass_properties["inertia"] 274 | 275 | if abs(mass) < 1e-9: 276 | print( 277 | warning( 278 | f"WARNING: part {instance['name']} has no mass, maybe you should assign a material to it ?" 279 | ) 280 | ) 281 | 282 | return mass, com[:3], np.reshape(inertia[:9], (3, 3)) 283 | 284 | def add_part(self, occurrence: dict): 285 | """ 286 | Add a part to the current link 287 | """ 288 | instance = occurrence["instance"] 289 | 290 | if instance["suppressed"]: 291 | return 292 | 293 | if instance["partId"] == "": 294 | print(warning(f"WARNING: Part '{instance['name']}' has no partId")) 295 | return 296 | 297 | part_name = instance["name"] 298 | extra = "" 299 | if instance["configuration"] != "default": 300 | extra = dim( 301 | " (configuration: " + self.printable_configuration(instance) + ")" 302 | ) 303 | symbol = "+" 304 | if self.part_is_ignored(part_name, "visual") or self.part_is_ignored( 305 | part_name, "collision" 306 | ): 307 | symbol = "-" 308 | extra += dim(" / ") 309 | if self.part_is_ignored(part_name, "visual"): 310 | extra += dim("(ignoring visual)") 311 | if self.part_is_ignored(part_name, "collision"): 312 | extra += dim(" (ignoring collision)") 313 | 314 | print(success(f"{symbol} Adding part {part_name}{extra}")) 315 | 316 | if self.part_is_ignored(part_name, "visual") and self.part_is_ignored( 317 | part_name, "collision" 318 | ): 319 | stl_file = None 320 | else: 321 | stl_file = self.get_stl(instance) 322 | 323 | # Obtain metadatas about part to retrieve color 324 | color = self.get_color(instance) 325 | 326 | # Obtain the instance dynamics 327 | mass, com, inertia = self.get_dynamics(instance) 328 | 329 | # Obtain part pose 330 | T_world_part = np.array(occurrence["transform"]).reshape(4, 4) 331 | 332 | # Adding non-ignored meshes 333 | meshes = [] 334 | mesh = Mesh(stl_file, color) 335 | if self.part_is_ignored(part_name, "visual"): 336 | mesh.visual = False 337 | if self.part_is_ignored(part_name, "collision"): 338 | mesh.collision = False 339 | if mesh.visual or mesh.collision: 340 | meshes.append(mesh) 341 | 342 | part = Part( 343 | self.unique_name(instance, "part"), 344 | T_world_part, 345 | mass, 346 | com, 347 | inertia, 348 | meshes, 349 | ) 350 | 351 | self.robot.links[-1].parts.append(part) 352 | 353 | def build_robot(self, body_id: int): 354 | """ 355 | Add recursively body nodes to the robot description. 356 | """ 357 | instance = self.assembly.body_instance(body_id) 358 | 359 | if body_id in self.assembly.link_names: 360 | link_name = self.assembly.link_names[body_id] 361 | else: 362 | link_name = self.unique_name(instance, "link") 363 | 364 | # Adding all the parts in the current link 365 | link = Link(link_name) 366 | self.robot.links.append(link) 367 | for occurrence in self.assembly.body_occurrences(body_id): 368 | if occurrence["instance"]["type"] == "Part": 369 | self.add_part(occurrence) 370 | if occurrence["fixed"]: 371 | link.fixed = True 372 | 373 | # Adding frames to the link 374 | for frame in self.assembly.frames: 375 | if frame.body_id == body_id: 376 | self.robot.links[-1].frames[frame.name] = frame.T_world_frame 377 | 378 | for children_body in self.assembly.tree_children[body_id]: 379 | dof = self.assembly.get_dof(body_id, children_body) 380 | child_body = dof.other_body(body_id) 381 | T_world_axis = dof.T_world_mate.copy() 382 | 383 | properties = self.config.joint_properties.get("default", {}) 384 | for joint_name in self.config.joint_properties: 385 | if fnmatch.fnmatch(dof.name, joint_name): 386 | properties = { 387 | **properties, 388 | **self.config.joint_properties[joint_name], 389 | } 390 | 391 | joint = Joint( 392 | dof.name, 393 | dof.joint_type, 394 | link, 395 | None, 396 | T_world_axis, 397 | properties, 398 | dof.limits, 399 | dof.axis, 400 | ) 401 | if dof.name in self.assembly.relations: 402 | source, ratio = self.assembly.relations[dof.name] 403 | joint.relation = Relation(source, ratio) 404 | 405 | # The joint is added before the recursive call, ensuring items in robot.joints has the 406 | # same order as recursive calls on the tree 407 | self.robot.joints.append(joint) 408 | 409 | joint.child = self.build_robot(child_body) 410 | 411 | return link 412 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Core dependencies 2 | colorama>=0.4.6 3 | commentjson 4 | numpy 5 | numpy-stl 6 | pybullet 7 | mujoco 8 | requests 9 | sphinx 10 | sphinx-rtd-theme 11 | transforms3d 12 | python-dotenv 13 | 14 | # Optional dependencies 15 | # pymeshlab -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README-pypi.md", "r", encoding="utf-8") as stream: 4 | long_description = stream.read() 5 | 6 | setuptools.setup( 7 | name="onshape_to_robot", 8 | version="1.7.5", 9 | author="Rhoban team", 10 | author_email="team@rhoban.com", 11 | description="Converting Onshape assembly to robot definition (URDF, SDF, MuJoCo) through Onshape API ", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/rhoban/onshape-to-robot/", 15 | packages=setuptools.find_packages(), 16 | entry_points={ 17 | "console_scripts": [ 18 | "onshape-to-robot=onshape_to_robot:export.main", 19 | "onshape-to-robot-bullet=onshape_to_robot:bullet.main", 20 | "onshape-to-robot-mujoco=onshape_to_robot:mujoco.main", 21 | "onshape-to-robot-clear-cache=onshape_to_robot:clear_cache.main", 22 | "onshape-to-robot-edit-shape=onshape_to_robot:edit_shape.main", 23 | "onshape-to-robot-pure-sketch=onshape_to_robot:pure_sketch.main", 24 | ] 25 | }, 26 | classifiers=[ 27 | "Programming Language :: Python :: 3", 28 | "License :: OSI Approved :: MIT License", 29 | "Operating System :: OS Independent", 30 | ], 31 | keywords="robot robotics cad design onshape bullet pybullet mujoco urdf sdf gazebo ros model kinematics", 32 | install_requires=[ 33 | "numpy", 34 | "requests", 35 | "commentjson", 36 | "colorama>=0.4.6", 37 | "numpy-stl", 38 | "transforms3d", 39 | "python-dotenv", 40 | ], 41 | extras_requires={ 42 | "pymeshlab": ["pymeshlab"], 43 | }, 44 | include_package_data=True, 45 | package_data={"": ["bullet/*", "assets/*", "README*.md"]}, 46 | python_requires=">=3.9", 47 | ) 48 | --------------------------------------------------------------------------------