├── Descriptor ├── core │ ├── __init__.py │ ├── transforms.py │ ├── utils.py │ ├── parts.py │ ├── manager.py │ ├── io.py │ ├── ui.py │ └── parser.py ├── Descriptor.manifest ├── gazebo_package │ ├── package.xml │ ├── LICENSE │ ├── CMakeLists.txt │ └── launch │ │ ├── robot_description.launch.py │ │ ├── gazebo.launch.py │ │ └── urdf.rviz ├── moveit_package │ ├── package.xml │ ├── launch │ │ ├── setup_assistant.launch.py │ │ └── urdf.rviz │ ├── LICENSE │ └── CMakeLists.txt ├── package_ros2 │ ├── package.xml │ ├── LICENSE │ ├── CMakeLists.txt │ └── launch │ │ ├── robot_description.launch.py │ │ └── urdf.rviz └── Descriptor.py ├── .gitattributes ├── docs ├── imagesforgettingstarted │ ├── 1.jpg │ ├── 10.jpg │ ├── 11.jpg │ ├── 12.jpg │ ├── 13.jpg │ ├── 19.jpg │ ├── 2.jpg │ ├── 20.jpg │ ├── 3.jpg │ ├── 4.jpg │ ├── 5.jpg │ ├── 6.jpg │ ├── 7.jpg │ ├── 8.jpg │ ├── 9.jpg │ └── gui.gif ├── configuration_sample.yaml └── Getting-Started.md ├── demoscenes ├── As-Built Joint.f3d ├── Ball Joint.f3d ├── PinSlot Joint.f3d ├── Planar Joint.f3d ├── Sample Joint v3.f3d └── Cylindrical Joint.f3d ├── LICENSE.md ├── README.md └── .gitignore /Descriptor/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.f3d filter=lfs diff=lfs merge=lfs -text 2 | -------------------------------------------------------------------------------- /docs/imagesforgettingstarted/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadop/fusion360descriptor/HEAD/docs/imagesforgettingstarted/1.jpg -------------------------------------------------------------------------------- /docs/imagesforgettingstarted/10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadop/fusion360descriptor/HEAD/docs/imagesforgettingstarted/10.jpg -------------------------------------------------------------------------------- /docs/imagesforgettingstarted/11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadop/fusion360descriptor/HEAD/docs/imagesforgettingstarted/11.jpg -------------------------------------------------------------------------------- /docs/imagesforgettingstarted/12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadop/fusion360descriptor/HEAD/docs/imagesforgettingstarted/12.jpg -------------------------------------------------------------------------------- /docs/imagesforgettingstarted/13.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadop/fusion360descriptor/HEAD/docs/imagesforgettingstarted/13.jpg -------------------------------------------------------------------------------- /docs/imagesforgettingstarted/19.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadop/fusion360descriptor/HEAD/docs/imagesforgettingstarted/19.jpg -------------------------------------------------------------------------------- /docs/imagesforgettingstarted/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadop/fusion360descriptor/HEAD/docs/imagesforgettingstarted/2.jpg -------------------------------------------------------------------------------- /docs/imagesforgettingstarted/20.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadop/fusion360descriptor/HEAD/docs/imagesforgettingstarted/20.jpg -------------------------------------------------------------------------------- /docs/imagesforgettingstarted/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadop/fusion360descriptor/HEAD/docs/imagesforgettingstarted/3.jpg -------------------------------------------------------------------------------- /docs/imagesforgettingstarted/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadop/fusion360descriptor/HEAD/docs/imagesforgettingstarted/4.jpg -------------------------------------------------------------------------------- /docs/imagesforgettingstarted/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadop/fusion360descriptor/HEAD/docs/imagesforgettingstarted/5.jpg -------------------------------------------------------------------------------- /docs/imagesforgettingstarted/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadop/fusion360descriptor/HEAD/docs/imagesforgettingstarted/6.jpg -------------------------------------------------------------------------------- /docs/imagesforgettingstarted/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadop/fusion360descriptor/HEAD/docs/imagesforgettingstarted/7.jpg -------------------------------------------------------------------------------- /docs/imagesforgettingstarted/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadop/fusion360descriptor/HEAD/docs/imagesforgettingstarted/8.jpg -------------------------------------------------------------------------------- /docs/imagesforgettingstarted/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadop/fusion360descriptor/HEAD/docs/imagesforgettingstarted/9.jpg -------------------------------------------------------------------------------- /docs/imagesforgettingstarted/gui.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadop/fusion360descriptor/HEAD/docs/imagesforgettingstarted/gui.gif -------------------------------------------------------------------------------- /demoscenes/As-Built Joint.f3d: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:1263c1086b2b4ab7c11d8ec906b37faf0af3f7f555601ab96885d28e94f1af7a 3 | size 2670096 4 | -------------------------------------------------------------------------------- /demoscenes/Ball Joint.f3d: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:113ebfcc604760e94eae0aa56dcd2aaf4fb4c2e9a6f20fd6df9b055ca06e0942 3 | size 1113342 4 | -------------------------------------------------------------------------------- /demoscenes/PinSlot Joint.f3d: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:de85ac8509f7e6b9c48c1ddb70378c656d456c11e3414793115235098a586b06 3 | size 90699 4 | -------------------------------------------------------------------------------- /demoscenes/Planar Joint.f3d: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:a321b7da611406e518aff01d3495a18d0de88ae39abc0c8498635d88f6eed7a2 3 | size 68830 4 | -------------------------------------------------------------------------------- /demoscenes/Sample Joint v3.f3d: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:bea60608c23e8afb6588f5e9f6edfa81ab5bed69f315018b9ab002cc496bee66 3 | size 63217 4 | -------------------------------------------------------------------------------- /demoscenes/Cylindrical Joint.f3d: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0e798fc7711ebe579c5be0c14f263ce4dd09df6eaa143d0aac96324634aa65dc 3 | size 658413 4 | -------------------------------------------------------------------------------- /Descriptor/Descriptor.manifest: -------------------------------------------------------------------------------- 1 | { 2 | "autodeskProduct": "Fusion360", 3 | "type": "script", 4 | "author": "syuntoku14, yanshil, cadop, apric0ts, simjeh", 5 | "description": { 6 | "": "Export stl and URDF file for ROS project" 7 | }, 8 | "supportedOS": "windows|mac", 9 | "editEnabled": true 10 | } -------------------------------------------------------------------------------- /Descriptor/gazebo_package/package.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | fusion2urdf 5 | 0.0.0 6 | The fusion2urdf package 7 | Spacemaster85 8 | MIT 9 | ament_cmake 10 | xacro 11 | 12 | ament_cmake 13 | 14 | 15 | -------------------------------------------------------------------------------- /Descriptor/moveit_package/package.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | fusion2urdf 5 | 0.0.0 6 | The fusion2urdf package 7 | Spacemaster85 8 | MIT 9 | ament_cmake 10 | xacro 11 | 12 | ament_cmake 13 | 14 | 15 | -------------------------------------------------------------------------------- /Descriptor/package_ros2/package.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | fusion2urdf 5 | 0.0.0 6 | The fusion2urdf package 7 | Spacemaster85 8 | MIT 9 | ament_cmake 10 | xacro 11 | 12 | ament_cmake 13 | 14 | 15 | -------------------------------------------------------------------------------- /Descriptor/moveit_package/launch/setup_assistant.launch.py: -------------------------------------------------------------------------------- 1 | from launch import LaunchDescription 2 | from launch.substitutions import PathJoinSubstitution 3 | from launch_ros.substitutions import FindPackageShare 4 | from moveit_configs_utils.launch_utils import ( 5 | add_debuggable_node, 6 | DeclareBooleanLaunchArg, 7 | ) 8 | 9 | def generate_launch_description(): 10 | ld = LaunchDescription() 11 | 12 | ld.add_action(DeclareBooleanLaunchArg("debug", default_value=False)) 13 | add_debuggable_node( 14 | ld, 15 | package="moveit_setup_assistant", 16 | executable="moveit_setup_assistant", 17 | arguments=[ 18 | "--urdf_path", 19 | PathJoinSubstitution( 20 | [ 21 | FindPackageShare("fusion2urdf"), 22 | "urdf", 23 | "fusion2urdf.xacro", 24 | ] 25 | ) 26 | ] 27 | ) 28 | 29 | return ld 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2025 SiBORG Design Lab 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. -------------------------------------------------------------------------------- /Descriptor/package_ros2/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Toshinori Kitamura 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Descriptor/Descriptor.py: -------------------------------------------------------------------------------- 1 | import adsk.core, adsk.fusion, traceback 2 | 3 | from .core.ui import config_settings 4 | from .core import manager 5 | 6 | ui_handlers = [] 7 | 8 | def run(context): 9 | ''' Entry point to the codebase for running the descriptor 10 | 11 | Parameters 12 | ---------- 13 | context : dict 14 | built-in requirement for Fusion link 15 | 16 | Returns 17 | ------- 18 | bool 19 | sucess code, not really used 20 | ''' 21 | 22 | ui = None 23 | try: 24 | app = adsk.core.Application.get() 25 | ui = app.userInterface 26 | product = app.activeProduct 27 | design = adsk.fusion.Design.cast(product) 28 | 29 | root = design.rootComponent 30 | # Set the global managers root component 31 | manager.Manager.root = root 32 | manager.Manager.design = design 33 | manager.Manager._app = app 34 | 35 | _ = config_settings(ui, ui_handlers) 36 | 37 | print('FINISHED') 38 | return 0 39 | 40 | except: 41 | if ui: 42 | ui.messageBox('Failed:\n{}'.format(traceback.format_exc())) 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /Descriptor/gazebo_package/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Toshinori Kitamura 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Descriptor/moveit_package/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Toshinori Kitamura 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Descriptor/package_ros2/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # Set minimum required version of cmake, project name and compile options 3 | ################################################################################ 4 | cmake_minimum_required(VERSION 3.5) 5 | project(fusion2urdf) 6 | 7 | if(NOT CMAKE_CXX_STANDARD) 8 | set(CMAKE_CXX_STANDARD 14) 9 | endif() 10 | 11 | if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") 12 | add_compile_options(-Wall -Wextra -Wpedantic) 13 | endif() 14 | 15 | ################################################################################ 16 | # Find ament packages and libraries for ament and system dependencies 17 | ################################################################################ 18 | find_package(ament_cmake REQUIRED) 19 | find_package(urdf REQUIRED) 20 | 21 | ################################################################################ 22 | # Install 23 | ################################################################################ 24 | install(DIRECTORY meshes urdf launch 25 | DESTINATION share/${PROJECT_NAME} 26 | ) 27 | 28 | ################################################################################ 29 | # Macro for ament package 30 | ################################################################################ 31 | ament_package() -------------------------------------------------------------------------------- /Descriptor/gazebo_package/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # Set minimum required version of cmake, project name and compile options 3 | ################################################################################ 4 | cmake_minimum_required(VERSION 3.5) 5 | project(fusion2urdf) 6 | 7 | if(NOT CMAKE_CXX_STANDARD) 8 | set(CMAKE_CXX_STANDARD 14) 9 | endif() 10 | 11 | if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") 12 | add_compile_options(-Wall -Wextra -Wpedantic) 13 | endif() 14 | 15 | ################################################################################ 16 | # Find ament packages and libraries for ament and system dependencies 17 | ################################################################################ 18 | find_package(ament_cmake REQUIRED) 19 | find_package(urdf REQUIRED) 20 | 21 | ################################################################################ 22 | # Install 23 | ################################################################################ 24 | install(DIRECTORY meshes urdf launch 25 | DESTINATION share/${PROJECT_NAME} 26 | ) 27 | 28 | ################################################################################ 29 | # Macro for ament package 30 | ################################################################################ 31 | ament_package() -------------------------------------------------------------------------------- /Descriptor/moveit_package/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # Set minimum required version of cmake, project name and compile options 3 | ################################################################################ 4 | cmake_minimum_required(VERSION 3.5) 5 | project(fusion2urdf) 6 | 7 | if(NOT CMAKE_CXX_STANDARD) 8 | set(CMAKE_CXX_STANDARD 14) 9 | endif() 10 | 11 | if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") 12 | add_compile_options(-Wall -Wextra -Wpedantic) 13 | endif() 14 | 15 | ################################################################################ 16 | # Find ament packages and libraries for ament and system dependencies 17 | ################################################################################ 18 | find_package(ament_cmake REQUIRED) 19 | find_package(urdf REQUIRED) 20 | 21 | ################################################################################ 22 | # Install 23 | ################################################################################ 24 | install(DIRECTORY meshes urdf launch 25 | DESTINATION share/${PROJECT_NAME} 26 | ) 27 | 28 | ################################################################################ 29 | # Macro for ament package 30 | ################################################################################ 31 | ament_package() -------------------------------------------------------------------------------- /Descriptor/core/transforms.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | import adsk.core 3 | 4 | import numpy as np 5 | from scipy.spatial.transform import Rotation 6 | 7 | def so3_to_euler(mat: adsk.core.Matrix3D) -> Tuple[float, float, float]: 8 | """Converts an SO3 rotation matrix to Euler angles 9 | 10 | Args: 11 | so3: Matrix3D coordinate transform 12 | 13 | Returns: 14 | tuple of Euler angles (size 3) 15 | 16 | """ 17 | so3 = np.zeros((3,3)) 18 | for i in range(3): 19 | for j in range(3): 20 | so3[i,j] = mat.getCell(i,j) 21 | ### first transform the matrix to euler angles 22 | r = Rotation.from_matrix(so3) 23 | yaw, pitch, roll = r.as_euler("ZYX", degrees=False) 24 | return (float(roll), float(pitch), float(yaw)) 25 | 26 | def origin2center_of_mass(inertia, center_of_mass, mass): 27 | """ 28 | convert the moment of the inertia about the world coordinate into 29 | that about center of mass coordinate 30 | 31 | 32 | Parameters 33 | ---------- 34 | moment of inertia about the world coordinate: [xx, yy, zz, xy, yz, xz] 35 | center_of_mass: [x, y, z] 36 | 37 | 38 | Returns 39 | ---------- 40 | moment of inertia about center of mass : [xx, yy, zz, xy, yz, xz] 41 | """ 42 | x = center_of_mass[0] 43 | y = center_of_mass[1] 44 | z = center_of_mass[2] 45 | translation_matrix = [y**2+z**2, x**2+z**2, x**2+y**2, 46 | -x*y, -y*z, -x*z] 47 | return [ round(i - mass*t, 6) for i, t in zip(inertia, translation_matrix)] 48 | -------------------------------------------------------------------------------- /Descriptor/gazebo_package/launch/robot_description.launch.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from launch import LaunchDescription 4 | from launch.substitutions import Command, FindExecutable, PathJoinSubstitution 5 | from launch_ros.actions import Node 6 | from launch_ros.substitutions import FindPackageShare 7 | 8 | def generate_launch_description(): 9 | 10 | 11 | robot_description_content = Command( 12 | [ 13 | PathJoinSubstitution([FindExecutable(name="xacro")]), 14 | " ", 15 | PathJoinSubstitution( 16 | [ 17 | FindPackageShare("fusion2urdf"), 18 | "urdf", 19 | "fusion2urdf.xacro", 20 | ] 21 | ), 22 | 23 | ] 24 | ) 25 | 26 | robot_description = {"robot_description": robot_description_content} 27 | 28 | robot_state_publisher_node = Node( 29 | package='robot_state_publisher', 30 | executable='robot_state_publisher', 31 | name='robot_state_publisher', 32 | output='screen', 33 | parameters=[robot_description] 34 | ) 35 | 36 | joint_state_publisher_gui_node = Node( 37 | package='joint_state_publisher_gui', 38 | executable='joint_state_publisher_gui', 39 | name='joint_state_publisher_gui' 40 | ) 41 | 42 | rviz_config_file = PathJoinSubstitution( 43 | [ 44 | FindPackageShare("fusion2urdf"), 45 | "launch", 46 | "urdf.rviz", 47 | ] 48 | ) 49 | 50 | rviz_node = Node( 51 | package='rviz2', 52 | executable='rviz2', 53 | name='rviz2', 54 | arguments=['-d', rviz_config_file], 55 | output='screen' 56 | ) 57 | 58 | return LaunchDescription([ 59 | robot_state_publisher_node, 60 | joint_state_publisher_gui_node, 61 | rviz_node 62 | ]) -------------------------------------------------------------------------------- /Descriptor/package_ros2/launch/robot_description.launch.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from launch import LaunchDescription 4 | from launch.substitutions import Command, FindExecutable, PathJoinSubstitution 5 | from launch_ros.actions import Node 6 | from launch_ros.substitutions import FindPackageShare 7 | 8 | def generate_launch_description(): 9 | 10 | 11 | robot_description_content = Command( 12 | [ 13 | PathJoinSubstitution([FindExecutable(name="xacro")]), 14 | " ", 15 | PathJoinSubstitution( 16 | [ 17 | FindPackageShare("fusion2urdf"), 18 | "urdf", 19 | "fusion2urdf.xacro", 20 | ] 21 | ), 22 | 23 | ] 24 | ) 25 | 26 | robot_description = {"robot_description": robot_description_content} 27 | 28 | robot_state_publisher_node = Node( 29 | package='robot_state_publisher', 30 | executable='robot_state_publisher', 31 | name='robot_state_publisher', 32 | output='screen', 33 | parameters=[robot_description] 34 | ) 35 | 36 | joint_state_publisher_gui_node = Node( 37 | package='joint_state_publisher_gui', 38 | executable='joint_state_publisher_gui', 39 | name='joint_state_publisher_gui' 40 | ) 41 | 42 | rviz_config_file = PathJoinSubstitution( 43 | [ 44 | FindPackageShare("fusion2urdf"), 45 | "launch", 46 | "urdf.rviz", 47 | ] 48 | ) 49 | 50 | rviz_node = Node( 51 | package='rviz2', 52 | executable='rviz2', 53 | name='rviz2', 54 | arguments=['-d', rviz_config_file], 55 | output='screen' 56 | ) 57 | 58 | return LaunchDescription([ 59 | robot_state_publisher_node, 60 | joint_state_publisher_gui_node, 61 | rviz_node 62 | ]) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fusion Descriptor 2 | 3 | ## Important Credits 4 | 5 | This a modified version of the fusion2urdf (https://github.com/syuntoku14/fusion2urdf) and fusion2pybullet (https://github.com/yanshil/Fusion2PyBullet) repositories. Many thanks for these authors and their communities building out the tools and showing what was possible. 6 | 7 | --- 8 | ### Why Another? 9 | 10 | This project was developed to solve some internal problems with our robot configuration and exporting. Between the stated end of development for the original repo and the priorities of the pybullet version, we felt it was best to make our own version and rewrite most of the fusion-side code. Naturally, we also wanted to understand the fusion API better, so this was a nice project to get started. 11 | 12 | --- 13 | ## Overview 14 | 15 | This project aims to help export link configurations and mechanical descriptions to XML (e.g. URDF) formats (and ideally, other formats as well in the future), from Autodesk Fusion. 16 | 17 | It provides a simple GUI for running the script and choosing a few different settings. 18 | 19 | --- 20 | ## Features 21 | 22 | - GUI interface for settings 23 | - Uses the grounded base as root node, that organizes the URDF tree by joint/link distance from root. 24 | - Handles rigid groups, coordinate transforms between origins, nested assemblies, etc 25 | - Allows switching between units 26 | - WYSIWYG stl exporting (exports model as you see on the screen) without needing to save design history or copy to a new file 27 | - Preview joint relationship before exporting 28 | - Export only URDF without Mesh (for fast viewing) 29 | - Can export component bodies separately as a visual element of URDF and a combined component as the collision 30 | - Can load export configuration from a Yaml file to speed up configuring the export. 31 | - Additional advanced options can be specified in the Yaml file, such as automatically mapping component and/or joint names to custom names in URDF. 32 | This is helpful to e.g. keep the URDF names constant as the Fusion design is updated, so that ROS configuration/code does not have to be changed. 33 | 34 | drawing 35 | 36 | --- 37 | ## License 38 | This project is licensed under the [MIT License](LICENSE.md). -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | Descriptor/.vscode/launch.json 131 | Descriptor/.vscode/settings.json 132 | 133 | #.vs dir 134 | .vs/ -------------------------------------------------------------------------------- /Descriptor/gazebo_package/launch/gazebo.launch.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from launch import LaunchDescription 4 | from launch.substitutions import Command, FindExecutable, PathJoinSubstitution 5 | from launch.actions import IncludeLaunchDescription, AppendEnvironmentVariable 6 | from launch_ros.actions import Node 7 | from launch_ros.substitutions import FindPackageShare 8 | from launch.launch_description_sources import PythonLaunchDescriptionSource 9 | from ament_index_python.packages import get_package_share_directory, get_package_prefix 10 | 11 | def generate_launch_description(): 12 | ###### ROBOT DESCRIPTION ###### 13 | robot_description_content = Command( 14 | [ 15 | PathJoinSubstitution([FindExecutable(name="xacro")]), 16 | " ", 17 | PathJoinSubstitution( 18 | [ 19 | FindPackageShare("fusion2urdf"), 20 | "urdf", 21 | "fusion2urdf.xacro", 22 | ] 23 | ), 24 | 25 | ] 26 | ) 27 | 28 | robot_description = {"robot_description": robot_description_content} 29 | 30 | robot_state_publisher_node = Node( 31 | package='robot_state_publisher', 32 | executable='robot_state_publisher', 33 | name='robot_state_publisher', 34 | output='screen', 35 | parameters=[robot_description] 36 | ) 37 | 38 | joint_state_publisher_gui_node = Node( 39 | package='joint_state_publisher_gui', 40 | executable='joint_state_publisher_gui', 41 | name='joint_state_publisher_gui' 42 | ) 43 | 44 | ###### RVIZ ###### 45 | rviz_config_file = PathJoinSubstitution( 46 | [ 47 | FindPackageShare("fusion2urdf"), 48 | "launch", 49 | "urdf.rviz", 50 | ] 51 | ) 52 | 53 | rviz_node = Node( 54 | package='rviz2', 55 | executable='rviz2', 56 | name='rviz2', 57 | arguments=['-d', rviz_config_file], 58 | output='screen' 59 | ) 60 | 61 | ###### GAZEBO ###### 62 | gazebo = IncludeLaunchDescription( 63 | PythonLaunchDescriptionSource([ 64 | PathJoinSubstitution( 65 | [ 66 | get_package_share_directory("gazebo_ros"), 67 | "launch", 68 | "gazebo.launch.py", 69 | ] 70 | ) 71 | ]), 72 | ) 73 | 74 | spawn_entity = Node(package='gazebo_ros', executable='spawn_entity.py', 75 | arguments=['-topic', 'robot_description', 76 | '-entity', 'fusion2urdf'], 77 | output='both') 78 | 79 | # Add the install path model path 80 | gazebo_env = AppendEnvironmentVariable("GAZEBO_MODEL_PATH", PathJoinSubstitution([get_package_prefix("fusion2urdf"), "share"])) 81 | 82 | return LaunchDescription([ 83 | gazebo_env, 84 | robot_state_publisher_node, 85 | joint_state_publisher_gui_node, 86 | rviz_node, 87 | gazebo, 88 | spawn_entity 89 | ]) -------------------------------------------------------------------------------- /Descriptor/core/utils.py: -------------------------------------------------------------------------------- 1 | import math 2 | import time 3 | from typing import Any, Dict, List, NoReturn, Optional, Sequence, Tuple, Union 4 | import adsk.core 5 | from .transforms import so3_to_euler 6 | 7 | LOG_DEBUG = False 8 | REFRESH_DELAY = 2.0 9 | 10 | start_time: Optional[float] = None 11 | last_refresh = 0.0 12 | 13 | def start_log_timer() -> None: 14 | global start_time 15 | start_time = time.time() 16 | 17 | def time_elapsed() -> float: 18 | assert start_time is not None 19 | return time.time() - start_time 20 | 21 | def format_name(input: str): 22 | translation_table = str.maketrans({':':'_', '-':'_', '.':'_', ' ':'', '(':'{', ')':'}'}) 23 | return input.translate(translation_table) 24 | 25 | def rename_if_duplicate(input: str, in_dict: Dict[str, Any]) -> str: 26 | count = 0 27 | new_name = input 28 | while in_dict.get(new_name) is not None: 29 | new_name = f"{input}_{count}" 30 | count += 1 31 | return new_name 32 | 33 | def convert_german(str_in): 34 | translation_table = str.maketrans({'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue', 'ß': 'ss'}) 35 | return str_in.translate(translation_table) 36 | 37 | viewport: Optional[adsk.core.Viewport] = None 38 | 39 | all_warnings: List[str] = [] 40 | 41 | def log(msg: str, level: Optional[adsk.core.LogLevels] = None) -> None: 42 | if not LOG_DEBUG and msg.startswith("DEBUG"): 43 | return 44 | if level is None: 45 | level = adsk.core.LogLevels.InfoLogLevel 46 | if msg.startswith("WARN"): 47 | level = adsk.core.LogLevels.WarningLogLevel 48 | elif msg.startswith("FATAL") or msg.startswith("ERR"): 49 | level = adsk.core.LogLevels.ErrorLogLevel 50 | adsk.core.Application.log(msg, level) 51 | if level == adsk.core.LogLevels.WarningLogLevel: 52 | all_warnings.append(msg) 53 | if viewport is not None: 54 | global last_refresh 55 | if time.time() >= last_refresh + REFRESH_DELAY: 56 | viewport.refresh() 57 | last_refresh = time.time() 58 | print(msg) 59 | 60 | def fatal(msg: str) -> NoReturn: 61 | log_msg = "FATAL ERROR: " + msg 62 | if start_time is not None: 63 | log_msg += f"\n\tTime Elapsed: {time_elapsed():.1f}s." 64 | log(log_msg) 65 | raise RuntimeError(msg) 66 | 67 | def mat_str(m: adsk.core.Matrix3D) -> str: 68 | return f"xyz={m.translation.asArray()} rpy={so3_to_euler(m)}" 69 | 70 | def vector_to_str(v: Union[adsk.core.Vector3D, adsk.core.Point3D, Sequence[float]], prec: int = 2) -> str: 71 | """Turn a verctor into a debug printout""" 72 | if isinstance(v, (adsk.core.Vector3D, adsk.core.Point3D)): 73 | v = v.asArray() 74 | return f"[{', '.join(f'{x:.{prec}f}' for x in v)}]" 75 | 76 | def rpy_to_str(rpy: Tuple[float, float, float]) -> str: 77 | return f"({', '.join(str(int(x/math.pi*180)) for x in rpy)})" 78 | 79 | def ct_to_str(ct: adsk.core.Matrix3D) -> str: 80 | """Turn a coordinate transform matrix into a debug printout""" 81 | rpy = so3_to_euler(ct) 82 | return (f"@{vector_to_str(ct.translation)}" 83 | f" rpy={rpy_to_str(rpy)}" 84 | f" ({' '.join(('[' + ','.join(str(int(x)) for x in v.asArray()) + ']') for v in ct.getAsCoordinateSystem()[1:])})") -------------------------------------------------------------------------------- /docs/configuration_sample.yaml: -------------------------------------------------------------------------------- 1 | ### Values to fill into the UI dialog 2 | RobotName: my_great_robot 3 | SaveMesh: true 4 | SubMesh: true 5 | MeshResolution: Low 6 | InertiaPrecision: Low 7 | TargetUnits: m 8 | TargetPlatform: MoveIt 9 | 10 | ### Additional URDF conversion options 11 | # Rename links and/or joints. 12 | # Maps Fusion names for occurrences and joints into URDF names for links and joints 13 | # For links, MergeLinks chould also be used instead (and would probably be better). 14 | NameMap: 15 | my_big_slider: Slider 16 | Rigid-Pulley-Spindle: Rev 17 | 18 | # If specified, serves as the root of the URDF regardless of what's grounded in Fusion 19 | # Can be either a name of the Fusion occurance (has to be a literal match), or a name 20 | # of a link from the MergeLinks section below. 21 | Root: Robot_Base 22 | 23 | # Merge several Fusion occurrences into a single URDF link 24 | # Can name either indivicual occurrences, or complete assemblies. 25 | # When an assembly occurrence is mentioned, all components in that assmembly will be included, 26 | # but it's not an error and would not change the result if some of the occurrences for components 27 | # and/or subassemblies are also specified explicitly. 28 | # The origin of the first occurrence specified would be used as the origin for the URDF link 29 | # (except for links immedeately downstream from a non-fixed joint - there the joint origin 30 | # would be the link origin, but the orientation would still follow that of the first occurrence). 31 | # The name of occurrences/assemblies can be specifies as follows: 32 | # - Literal name 33 | # - Occurence name pattern with exactly one wildcard symbol "*" (wildcard matches any substring) 34 | # - as a +-deliminated full path suffix, each element potentially containing a single wildcard 35 | # In each case, there should be exactly one occurrence matching the specification 36 | MergeLinks: 37 | Robot_Base: 38 | - Optical Breadboard - 1000x1000 v* 39 | - Post:1 40 | - Second optical breadboard v* 41 | - Slider Assembly v*+SliderBase v* 42 | AddOnComponent1: 43 | - Slider Assembly v*+*+AddOn v* 44 | 45 | # Use this to specify parts of the Design that should be exported, but not included in the 46 | # main urdf/.xacro. If present, that these links and the joints that connect 47 | # them to the rest of the robot would not be included in urdf/.xacro, and 48 | # a urdf/-full.xacro would be created containing the full set. This is useful 49 | # e.g. when these extra links are meant to be used as an AttachedCollisionObject with MoveIT 50 | # These links should not have any downstream joints/links. 51 | Extras: 52 | - AddOnComponent1 53 | - AddOnComponent2 54 | 55 | # When present and non-empty, will generate a urdf/locations.yaml containing specified locations w.r.t 56 | # specified links. For each UDFL link, specify the locations.yaml and the Fusion occurrence to take the origin from 57 | # This is helpful if you want to e.g. use MoveIt and specify some parts of your Fusion model as "attached" objects 58 | # in your scene, rather than a part of your robot (note: you will need to write your own code to set that up). 59 | Locations: 60 | AddOnComponent1: 61 | Base: Slider Assembly v*+*+AddOn v*+Base* 62 | Tip: Slider Assembly v*+*+AddOn v*+Tip* -------------------------------------------------------------------------------- /Descriptor/package_ros2/launch/urdf.rviz: -------------------------------------------------------------------------------- 1 | Panels: 2 | - Class: rviz_common/Displays 3 | Help Height: 78 4 | Name: Displays 5 | Property Tree Widget: 6 | Expanded: 7 | - /Global Options1 8 | - /Status1 9 | - /RobotModel1 10 | - /TF1 11 | Splitter Ratio: 0.5 12 | Tree Height: 617 13 | - Class: rviz_common/Selection 14 | Name: Selection 15 | - Class: rviz_common/Tool Properties 16 | Expanded: 17 | - /2D Goal Pose1 18 | - /Publish Point1 19 | Name: Tool Properties 20 | Splitter Ratio: 0.5886790156364441 21 | - Class: rviz_common/Views 22 | Expanded: 23 | - /Current View1 24 | Name: Views 25 | Splitter Ratio: 0.5 26 | Visualization Manager: 27 | Class: "" 28 | Displays: 29 | - Alpha: 0.5 30 | Cell Size: 1 31 | Class: rviz_default_plugins/Grid 32 | Color: 160; 160; 164 33 | Enabled: true 34 | Line Style: 35 | Line Width: 0.029999999329447746 36 | Value: Lines 37 | Name: Grid 38 | Normal Cell Count: 0 39 | Offset: 40 | X: 0 41 | Y: 0 42 | Z: 0 43 | Plane: XY 44 | Plane Cell Count: 10 45 | Reference Frame: 46 | Value: true 47 | - Alpha: 1 48 | Class: rviz_default_plugins/RobotModel 49 | Collision Enabled: false 50 | Description File: "" 51 | Description Source: Topic 52 | Description Topic: 53 | Depth: 5 54 | Durability Policy: Volatile 55 | History Policy: Keep Last 56 | Reliability Policy: Reliable 57 | Value: /robot_description 58 | Enabled: true 59 | Links: 60 | All Links Enabled: true 61 | Expand Joint Details: false 62 | Expand Link Details: false 63 | Expand Tree: false 64 | Link Tree Style: Links in Alphabetic Order 65 | Name: RobotModel 66 | TF Prefix: "" 67 | Update Interval: 0 68 | Value: true 69 | Visual Enabled: true 70 | - Class: rviz_default_plugins/TF 71 | Enabled: true 72 | Frame Timeout: 15 73 | Marker Scale: 0.5 74 | Name: TF 75 | Show Arrows: true 76 | Show Axes: true 77 | Show Names: false 78 | Update Interval: 0 79 | Value: true 80 | Enabled: true 81 | Global Options: 82 | Background Color: 48; 48; 48 83 | Fixed Frame: dummy_link 84 | Frame Rate: 30 85 | Name: root 86 | Tools: 87 | - Class: rviz_default_plugins/Interact 88 | Hide Inactive Objects: true 89 | - Class: rviz_default_plugins/MoveCamera 90 | - Class: rviz_default_plugins/Select 91 | - Class: rviz_default_plugins/FocusCamera 92 | - Class: rviz_default_plugins/Measure 93 | Line color: 128; 128; 0 94 | - Class: rviz_default_plugins/SetInitialPose 95 | Topic: 96 | Depth: 5 97 | Durability Policy: Volatile 98 | History Policy: Keep Last 99 | Reliability Policy: Reliable 100 | Value: /initialpose 101 | - Class: rviz_default_plugins/SetGoal 102 | Topic: 103 | Depth: 5 104 | Durability Policy: Volatile 105 | History Policy: Keep Last 106 | Reliability Policy: Reliable 107 | Value: /goal_pose 108 | - Class: rviz_default_plugins/PublishPoint 109 | Single click: true 110 | Topic: 111 | Depth: 5 112 | Durability Policy: Volatile 113 | History Policy: Keep Last 114 | Reliability Policy: Reliable 115 | Value: /clicked_point 116 | Transformation: 117 | Current: 118 | Class: rviz_default_plugins/TF 119 | Value: true 120 | Views: 121 | Current: 122 | Class: rviz_default_plugins/Orbit 123 | Distance: 0.8402727246284485 124 | Enable Stereo Rendering: 125 | Stereo Eye Separation: 0.05999999865889549 126 | Stereo Focal Distance: 1 127 | Swap Stereo Eyes: false 128 | Value: false 129 | Focal Point: 130 | X: 0 131 | Y: 0 132 | Z: 0 133 | Focal Shape Fixed Size: true 134 | Focal Shape Size: 0.05000000074505806 135 | Invert Z Axis: false 136 | Name: Current View 137 | Near Clip Distance: 0.009999999776482582 138 | Pitch: 0.6097970008850098 139 | Target Frame: 140 | Value: Orbit (rviz) 141 | Yaw: 0.5103846192359924 142 | Saved: ~ 143 | Window Geometry: 144 | Displays: 145 | collapsed: false 146 | Height: 846 147 | Hide Left Dock: false 148 | Hide Right Dock: false 149 | QMainWindow State: 000000ff00000000fd000000040000000000000156000002f4fc0200000008fb0000001200530065006c0065006300740069006f006e00000001e10000009b0000005c00fffffffb0000001e0054006f006f006c002000500072006f007000650072007400690065007302000001ed000001df00000185000000a3fb000000120056006900650077007300200054006f006f02000001df000002110000018500000122fb000000200054006f006f006c002000500072006f0070006500720074006900650073003203000002880000011d000002210000017afb000000100044006900730070006c006100790073010000003d000002f4000000c900fffffffb0000002000730065006c0065006300740069006f006e00200062007500660066006500720200000138000000aa0000023a00000294fb00000014005700690064006500530074006500720065006f02000000e6000000d2000003ee0000030bfb0000000c004b0069006e0065006300740200000186000001060000030c00000261000000010000010f000002f4fc0200000003fb0000001e0054006f006f006c002000500072006f00700065007200740069006500730100000041000000780000000000000000fb0000000a00560069006500770073010000003d000002f4000000a400fffffffb0000001200530065006c0065006300740069006f006e010000025a000000b200000000000000000000000200000490000000a9fc0100000001fb0000000a00560069006500770073030000004e00000080000002e10000019700000003000004420000003efc0100000002fb0000000800540069006d00650100000000000004420000000000000000fb0000000800540069006d006501000000000000045000000000000000000000023f000002f400000004000000040000000800000008fc0000000100000002000000010000000a0054006f006f006c00730100000000ffffffff0000000000000000 150 | Selection: 151 | collapsed: false 152 | Tool Properties: 153 | collapsed: false 154 | Views: 155 | collapsed: false 156 | Width: 1200 157 | X: 201 158 | Y: 69 159 | -------------------------------------------------------------------------------- /Descriptor/gazebo_package/launch/urdf.rviz: -------------------------------------------------------------------------------- 1 | Panels: 2 | - Class: rviz_common/Displays 3 | Help Height: 78 4 | Name: Displays 5 | Property Tree Widget: 6 | Expanded: 7 | - /Global Options1 8 | - /Status1 9 | - /RobotModel1 10 | - /TF1 11 | Splitter Ratio: 0.5 12 | Tree Height: 617 13 | - Class: rviz_common/Selection 14 | Name: Selection 15 | - Class: rviz_common/Tool Properties 16 | Expanded: 17 | - /2D Goal Pose1 18 | - /Publish Point1 19 | Name: Tool Properties 20 | Splitter Ratio: 0.5886790156364441 21 | - Class: rviz_common/Views 22 | Expanded: 23 | - /Current View1 24 | Name: Views 25 | Splitter Ratio: 0.5 26 | Visualization Manager: 27 | Class: "" 28 | Displays: 29 | - Alpha: 0.5 30 | Cell Size: 1 31 | Class: rviz_default_plugins/Grid 32 | Color: 160; 160; 164 33 | Enabled: true 34 | Line Style: 35 | Line Width: 0.029999999329447746 36 | Value: Lines 37 | Name: Grid 38 | Normal Cell Count: 0 39 | Offset: 40 | X: 0 41 | Y: 0 42 | Z: 0 43 | Plane: XY 44 | Plane Cell Count: 10 45 | Reference Frame: 46 | Value: true 47 | - Alpha: 1 48 | Class: rviz_default_plugins/RobotModel 49 | Collision Enabled: false 50 | Description File: "" 51 | Description Source: Topic 52 | Description Topic: 53 | Depth: 5 54 | Durability Policy: Volatile 55 | History Policy: Keep Last 56 | Reliability Policy: Reliable 57 | Value: /robot_description 58 | Enabled: true 59 | Links: 60 | All Links Enabled: true 61 | Expand Joint Details: false 62 | Expand Link Details: false 63 | Expand Tree: false 64 | Link Tree Style: Links in Alphabetic Order 65 | Name: RobotModel 66 | TF Prefix: "" 67 | Update Interval: 0 68 | Value: true 69 | Visual Enabled: true 70 | - Class: rviz_default_plugins/TF 71 | Enabled: true 72 | Frame Timeout: 15 73 | Marker Scale: 0.5 74 | Name: TF 75 | Show Arrows: true 76 | Show Axes: true 77 | Show Names: false 78 | Update Interval: 0 79 | Value: true 80 | Enabled: true 81 | Global Options: 82 | Background Color: 48; 48; 48 83 | Fixed Frame: dummy_link 84 | Frame Rate: 30 85 | Name: root 86 | Tools: 87 | - Class: rviz_default_plugins/Interact 88 | Hide Inactive Objects: true 89 | - Class: rviz_default_plugins/MoveCamera 90 | - Class: rviz_default_plugins/Select 91 | - Class: rviz_default_plugins/FocusCamera 92 | - Class: rviz_default_plugins/Measure 93 | Line color: 128; 128; 0 94 | - Class: rviz_default_plugins/SetInitialPose 95 | Topic: 96 | Depth: 5 97 | Durability Policy: Volatile 98 | History Policy: Keep Last 99 | Reliability Policy: Reliable 100 | Value: /initialpose 101 | - Class: rviz_default_plugins/SetGoal 102 | Topic: 103 | Depth: 5 104 | Durability Policy: Volatile 105 | History Policy: Keep Last 106 | Reliability Policy: Reliable 107 | Value: /goal_pose 108 | - Class: rviz_default_plugins/PublishPoint 109 | Single click: true 110 | Topic: 111 | Depth: 5 112 | Durability Policy: Volatile 113 | History Policy: Keep Last 114 | Reliability Policy: Reliable 115 | Value: /clicked_point 116 | Transformation: 117 | Current: 118 | Class: rviz_default_plugins/TF 119 | Value: true 120 | Views: 121 | Current: 122 | Class: rviz_default_plugins/Orbit 123 | Distance: 0.8402727246284485 124 | Enable Stereo Rendering: 125 | Stereo Eye Separation: 0.05999999865889549 126 | Stereo Focal Distance: 1 127 | Swap Stereo Eyes: false 128 | Value: false 129 | Focal Point: 130 | X: 0 131 | Y: 0 132 | Z: 0 133 | Focal Shape Fixed Size: true 134 | Focal Shape Size: 0.05000000074505806 135 | Invert Z Axis: false 136 | Name: Current View 137 | Near Clip Distance: 0.009999999776482582 138 | Pitch: 0.6097970008850098 139 | Target Frame: 140 | Value: Orbit (rviz) 141 | Yaw: 0.5103846192359924 142 | Saved: ~ 143 | Window Geometry: 144 | Displays: 145 | collapsed: false 146 | Height: 846 147 | Hide Left Dock: false 148 | Hide Right Dock: false 149 | QMainWindow State: 000000ff00000000fd000000040000000000000156000002f4fc0200000008fb0000001200530065006c0065006300740069006f006e00000001e10000009b0000005c00fffffffb0000001e0054006f006f006c002000500072006f007000650072007400690065007302000001ed000001df00000185000000a3fb000000120056006900650077007300200054006f006f02000001df000002110000018500000122fb000000200054006f006f006c002000500072006f0070006500720074006900650073003203000002880000011d000002210000017afb000000100044006900730070006c006100790073010000003d000002f4000000c900fffffffb0000002000730065006c0065006300740069006f006e00200062007500660066006500720200000138000000aa0000023a00000294fb00000014005700690064006500530074006500720065006f02000000e6000000d2000003ee0000030bfb0000000c004b0069006e0065006300740200000186000001060000030c00000261000000010000010f000002f4fc0200000003fb0000001e0054006f006f006c002000500072006f00700065007200740069006500730100000041000000780000000000000000fb0000000a00560069006500770073010000003d000002f4000000a400fffffffb0000001200530065006c0065006300740069006f006e010000025a000000b200000000000000000000000200000490000000a9fc0100000001fb0000000a00560069006500770073030000004e00000080000002e10000019700000003000004420000003efc0100000002fb0000000800540069006d00650100000000000004420000000000000000fb0000000800540069006d006501000000000000045000000000000000000000023f000002f400000004000000040000000800000008fc0000000100000002000000010000000a0054006f006f006c00730100000000ffffffff0000000000000000 150 | Selection: 151 | collapsed: false 152 | Tool Properties: 153 | collapsed: false 154 | Views: 155 | collapsed: false 156 | Width: 1200 157 | X: 201 158 | Y: 69 159 | -------------------------------------------------------------------------------- /Descriptor/moveit_package/launch/urdf.rviz: -------------------------------------------------------------------------------- 1 | Panels: 2 | - Class: rviz_common/Displays 3 | Help Height: 78 4 | Name: Displays 5 | Property Tree Widget: 6 | Expanded: 7 | - /Global Options1 8 | - /Status1 9 | - /RobotModel1 10 | - /TF1 11 | Splitter Ratio: 0.5 12 | Tree Height: 617 13 | - Class: rviz_common/Selection 14 | Name: Selection 15 | - Class: rviz_common/Tool Properties 16 | Expanded: 17 | - /2D Goal Pose1 18 | - /Publish Point1 19 | Name: Tool Properties 20 | Splitter Ratio: 0.5886790156364441 21 | - Class: rviz_common/Views 22 | Expanded: 23 | - /Current View1 24 | Name: Views 25 | Splitter Ratio: 0.5 26 | Visualization Manager: 27 | Class: "" 28 | Displays: 29 | - Alpha: 0.5 30 | Cell Size: 1 31 | Class: rviz_default_plugins/Grid 32 | Color: 160; 160; 164 33 | Enabled: true 34 | Line Style: 35 | Line Width: 0.029999999329447746 36 | Value: Lines 37 | Name: Grid 38 | Normal Cell Count: 0 39 | Offset: 40 | X: 0 41 | Y: 0 42 | Z: 0 43 | Plane: XY 44 | Plane Cell Count: 10 45 | Reference Frame: 46 | Value: true 47 | - Alpha: 1 48 | Class: rviz_default_plugins/RobotModel 49 | Collision Enabled: false 50 | Description File: "" 51 | Description Source: Topic 52 | Description Topic: 53 | Depth: 5 54 | Durability Policy: Volatile 55 | History Policy: Keep Last 56 | Reliability Policy: Reliable 57 | Value: /robot_description 58 | Enabled: true 59 | Links: 60 | All Links Enabled: true 61 | Expand Joint Details: false 62 | Expand Link Details: false 63 | Expand Tree: false 64 | Link Tree Style: Links in Alphabetic Order 65 | Name: RobotModel 66 | TF Prefix: "" 67 | Update Interval: 0 68 | Value: true 69 | Visual Enabled: true 70 | - Class: rviz_default_plugins/TF 71 | Enabled: true 72 | Frame Timeout: 15 73 | Marker Scale: 0.5 74 | Name: TF 75 | Show Arrows: true 76 | Show Axes: true 77 | Show Names: false 78 | Update Interval: 0 79 | Value: true 80 | Enabled: true 81 | Global Options: 82 | Background Color: 48; 48; 48 83 | Fixed Frame: dummy_link 84 | Frame Rate: 30 85 | Name: root 86 | Tools: 87 | - Class: rviz_default_plugins/Interact 88 | Hide Inactive Objects: true 89 | - Class: rviz_default_plugins/MoveCamera 90 | - Class: rviz_default_plugins/Select 91 | - Class: rviz_default_plugins/FocusCamera 92 | - Class: rviz_default_plugins/Measure 93 | Line color: 128; 128; 0 94 | - Class: rviz_default_plugins/SetInitialPose 95 | Topic: 96 | Depth: 5 97 | Durability Policy: Volatile 98 | History Policy: Keep Last 99 | Reliability Policy: Reliable 100 | Value: /initialpose 101 | - Class: rviz_default_plugins/SetGoal 102 | Topic: 103 | Depth: 5 104 | Durability Policy: Volatile 105 | History Policy: Keep Last 106 | Reliability Policy: Reliable 107 | Value: /goal_pose 108 | - Class: rviz_default_plugins/PublishPoint 109 | Single click: true 110 | Topic: 111 | Depth: 5 112 | Durability Policy: Volatile 113 | History Policy: Keep Last 114 | Reliability Policy: Reliable 115 | Value: /clicked_point 116 | Transformation: 117 | Current: 118 | Class: rviz_default_plugins/TF 119 | Value: true 120 | Views: 121 | Current: 122 | Class: rviz_default_plugins/Orbit 123 | Distance: 0.8402727246284485 124 | Enable Stereo Rendering: 125 | Stereo Eye Separation: 0.05999999865889549 126 | Stereo Focal Distance: 1 127 | Swap Stereo Eyes: false 128 | Value: false 129 | Focal Point: 130 | X: 0 131 | Y: 0 132 | Z: 0 133 | Focal Shape Fixed Size: true 134 | Focal Shape Size: 0.05000000074505806 135 | Invert Z Axis: false 136 | Name: Current View 137 | Near Clip Distance: 0.009999999776482582 138 | Pitch: 0.6097970008850098 139 | Target Frame: 140 | Value: Orbit (rviz) 141 | Yaw: 0.5103846192359924 142 | Saved: ~ 143 | Window Geometry: 144 | Displays: 145 | collapsed: false 146 | Height: 846 147 | Hide Left Dock: false 148 | Hide Right Dock: false 149 | QMainWindow State: 000000ff00000000fd000000040000000000000156000002f4fc0200000008fb0000001200530065006c0065006300740069006f006e00000001e10000009b0000005c00fffffffb0000001e0054006f006f006c002000500072006f007000650072007400690065007302000001ed000001df00000185000000a3fb000000120056006900650077007300200054006f006f02000001df000002110000018500000122fb000000200054006f006f006c002000500072006f0070006500720074006900650073003203000002880000011d000002210000017afb000000100044006900730070006c006100790073010000003d000002f4000000c900fffffffb0000002000730065006c0065006300740069006f006e00200062007500660066006500720200000138000000aa0000023a00000294fb00000014005700690064006500530074006500720065006f02000000e6000000d2000003ee0000030bfb0000000c004b0069006e0065006300740200000186000001060000030c00000261000000010000010f000002f4fc0200000003fb0000001e0054006f006f006c002000500072006f00700065007200740069006500730100000041000000780000000000000000fb0000000a00560069006500770073010000003d000002f4000000a400fffffffb0000001200530065006c0065006300740069006f006e010000025a000000b200000000000000000000000200000490000000a9fc0100000001fb0000000a00560069006500770073030000004e00000080000002e10000019700000003000004420000003efc0100000002fb0000000800540069006d00650100000000000004420000000000000000fb0000000800540069006d006501000000000000045000000000000000000000023f000002f400000004000000040000000800000008fc0000000100000002000000010000000a0054006f006f006c00730100000000ffffffff0000000000000000 150 | Selection: 151 | collapsed: false 152 | Tool Properties: 153 | collapsed: false 154 | Views: 155 | collapsed: false 156 | Width: 1200 157 | X: 201 158 | Y: 69 159 | -------------------------------------------------------------------------------- /docs/Getting-Started.md: -------------------------------------------------------------------------------- 1 | Fusion Descriptor - Getting Started 2 | ================= 3 | 4 | Installation & Running 5 | ----------------- 6 | 7 | - Download the repository as .zip or run `git clone https://github.com/cadop/fusion360descriptor.git` in Git Bash to download the repository. 8 | - Navigate to the **Utilities** tab in Fusion and click on **Scripts and Add-Ins**. 9 | - Click on the green + to add the script into Fusion. 10 | - Add the **Descriptor** folder from the zip file to the scripts. You may have to unzip the file after downloading from Github 11 | - Click on **Descriptor** then click on **Run**. The GUI will appear. 12 | - Rather than configuring the export manually in the GUI, you can create a YAML configuration file to control the export. A number of more 13 | advanced features are only available via the configuraiton file. See `configuration_sample.yaml` for more information. 14 | 15 | An extensive, step by step procedure can be found at [Step-by-Step Guide](#step-by-step-guide). 16 | 17 | FAQ 18 | ----------------- 19 | 20 | **Q:** How can I visualize the URDF generated by my Fusion model? 21 | 22 | **A:** We recommend PyBullet; the converter generates a sample `hello_bullet.py` to get started with your URDF. 23 | 24 | **Q:** How do I install PyBullet? 25 | 26 | **A:** Ensure that Python, pip, and Microsoft Visual Studio C++ build tools are installed. Check with `which python` and `which pip`. At the command line, run `pip install pybullet`. 27 | 28 | **Q:** How can I create more advanced models? 29 | 30 | **A:** A good place to find pre-built components is the *McMASTERR-CARR* component directory. To go to this, click on **Insert** in the top menu bar, and in the drop down menu, go to **Insert McMASTERR-Carr Component**. Then, you can find your desired component, click on the specifications of choice, go to **Product Detail**, and press **Download**. 31 | 32 | Installation & Running 33 | ----------------- 34 | 35 | - Download the repository as .zip by clicking the green **Code** button above and then **Download Zip**. 36 | 37 | drawing 38 | 39 | - Unzip to a permanent folder of your choice. 40 | - Navigate to the **Utilities** tab in Fusion and click on **Scripts and Add-Ins**. 41 | 42 | drawing 43 | 44 | - Click on the green + to add the script into Fusion. 45 | 46 | - Add the **Descriptor** folder from the extracted files to the scripts. 47 | 48 | drawing 49 | 50 | - Click on **Descriptor** under **My Scripts** then click on **Run**. The GUI will appear. 51 | 52 | drawing 53 | 54 | 55 | Step-by-Step Guide 56 | ------------------ 57 | 58 | **Creating a Fusion Model** 59 | 60 | - Navigate to the **Solid** tab at the top of the screen and click on **Create Sketch**. 61 | 62 | 63 | drawing 64 | 65 | - Choose a plane to work on by clicking any of the highlighted squares. At the top of your screen, you should now be under the **Sketch** tab. 66 | 67 | drawing 68 | 69 | - Click on the **2-Point Rectangle** and drag on the workspace to create a rectangle with a size of your choosing. 70 | 71 | drawing 72 | 73 | - Click on **Finish Sketch**. You should now be back under the **Solid** tab. 74 | - Click on **Extrude** and drag on the rectangle that you have created until you have a 3D shape of your choosing. Click on on **OK**. 75 | 76 | drawing 77 | 78 | - Now, you should have a 3D shape categorized under **Bodies** on the left side. 79 | 80 | drawing 81 | 82 | - Create a second rectangle following the above instructions that is adjacent to the first one. Make sure they are touching in some way. 83 | 84 | drawing 85 | 86 | - You can move a body by right clicking on the body name on the left side and clicking **Move/Copy**, or by pressing **M** on the keyboard. 87 | 88 | 89 | drawing 90 | 91 | - Right click on **Bodies** and click on *Create Components from Bodies*. Both bodies should now be **Components**. 92 | 93 | drawing 94 | 95 | - Make sure that one of these components are grounded. To do so, right click on the component on the left side and click on **Ground**. 96 | - A red indicator should appear next to the name of the component that shows it is grounded. 97 | 98 | drawing 99 | 100 | - Now, go to the **Surface** tab and click on **Joint**. 101 | 102 | drawing 103 | 104 | - Add the joint to each component, where they are touching. Make sure this joint has motion type **Rigid**. A **Joints** category should appear on the left side. 105 | - Navigate to the **Utilities** tab at the top of the screen and click on **Scripts and Add-Ins**. 106 | 107 | drawing 108 | 109 | - Click on the green + to add the script into Fusion. 110 | 111 | drawing 112 | 113 | - Add the **Descriptor** folder from the zip file to the scripts. 114 | - Click on **Descriptor** then click on **Run**. The GUI will appear. 115 | - Add a save directory for the output and select the desired options. 116 | - Note: rather than selecting the desired export options manually in the GUI (and having to re-enter them every time you want to re-export), 117 | you can create a YAML configuration file to control the export. Also, a number of more advanced features are only available via the 118 | configuraiton file. See `configuration_sample.yaml` for more information. 119 | 120 | drawing 121 | 122 | - Click on **Generate**. The output can be found where the save directory is. 123 | 124 | -------------------------------------------------------------------------------- /Descriptor/core/parts.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Created on Sun May 12 20:17:17 2019 4 | 5 | @author: syuntoku 6 | 7 | Modified by cadop Dec 19 2021 8 | """ 9 | 10 | import math 11 | from typing import List, Optional, Sequence 12 | from xml.etree.ElementTree import Element, SubElement 13 | from xml.etree import ElementTree 14 | from xml.dom import minidom 15 | from . import utils 16 | 17 | MAX_ROUND_TO_ZERO = 5e-11 # in mm, radians 18 | HALF_PI = math.pi * 0.5 19 | def _round(val: float, unit: float) -> float: 20 | units = val/unit 21 | r = round(units) * unit 22 | if abs(val - r) <= MAX_ROUND_TO_ZERO: 23 | return r 24 | return val 25 | 26 | def round_mm(val:float) -> float: 27 | return _round(val, 0.0001) 28 | 29 | def round_rads(val:float) -> float: 30 | return _round(val, HALF_PI) 31 | 32 | class Location: 33 | def __init__(self, name: str, xyz: Sequence[float], rpy: Sequence[float]): 34 | self.name = name 35 | self.xyz = [round_mm(x) for x in xyz] 36 | self.rpy = [round_rads(r) for r in rpy] 37 | 38 | class Joint(Location): 39 | 40 | # Defaults for all joints. Need be be floats, not ints 41 | effort_limit = 100.0 42 | vel_limit = 100.0 43 | 44 | def __init__(self, name: str, xyz: Sequence[float], rpy: Sequence[float], axis: Sequence[float], parent: str, child:str, joint_type: str, upper_limit: float, lower_limit: float): 45 | """ 46 | Attributes 47 | ---------- 48 | name: str 49 | name of the joint 50 | type: str 51 | type of the joint(ex: rev) 52 | xyz: [x, y, z] 53 | coordinate of the joint 54 | axis: [x, y, z] 55 | coordinate of axis of the joint 56 | parent: str 57 | parent link 58 | child: str 59 | child link 60 | joint_xml: str 61 | generated xml describing about the joint 62 | tran_xml: str 63 | generated xml describing about the transmission 64 | """ 65 | super().__init__(name, xyz, rpy) 66 | self.type = joint_type 67 | self.parent = parent 68 | self.child = child 69 | self._joint_xml = None 70 | self._tran_xml = None 71 | self.axis = [round_mm(a) for a in axis] # for 'revolute' and 'continuous' 72 | self.upper_limit = upper_limit # for 'revolute' and 'prismatic' 73 | self.lower_limit = lower_limit # for 'revolute' and 'prismatic' 74 | 75 | def joint_xml(self) -> Element: 76 | """ 77 | Generate the joint xml 78 | """ 79 | 80 | joint = Element('joint') 81 | joint.attrib = {'name':utils.format_name(self.name), 'type':self.type} 82 | 83 | origin = SubElement(joint, 'origin') 84 | origin.attrib = {'xyz':' '.join([str(_) for _ in self.xyz]), 'rpy':' '.join([str(_) for _ in self.rpy])} 85 | 86 | parent = SubElement(joint, 'parent') 87 | self.parent = utils.format_name(self.parent) 88 | parent.attrib = {'link':self.parent} 89 | 90 | child = SubElement(joint, 'child') 91 | self.child = utils.format_name(self.child) 92 | child.attrib = {'link':self.child} 93 | 94 | if self.type == 'revolute' or self.type == 'continuous' or self.type == 'prismatic': 95 | axis = SubElement(joint, 'axis') 96 | axis.attrib = {'xyz':' '.join([str(_) for _ in self.axis])} 97 | if self.type == 'revolute' or self.type == 'prismatic': 98 | limit = SubElement(joint, 'limit') 99 | limit.attrib = {'upper': str(self.upper_limit), 'lower': str(self.lower_limit), 100 | 'effort': f'{Joint.effort_limit}', 'velocity': f'{Joint.vel_limit}'} 101 | 102 | return joint 103 | 104 | def transmission_xml(self) -> Element: 105 | """ 106 | Generate the tran_xml and hold it by self.tran_xml 107 | 108 | 109 | Notes 110 | ----------- 111 | mechanicalTransmission: 1 112 | type: transmission interface/SimpleTransmission 113 | hardwareInterface: PositionJointInterface 114 | """ 115 | 116 | tran = Element('transmission') 117 | tran.attrib = {'name':utils.format_name(self.name) + '_tran'} 118 | 119 | joint_type = SubElement(tran, 'type') 120 | joint_type.text = 'transmission_interface/SimpleTransmission' 121 | 122 | joint = SubElement(tran, 'joint') 123 | joint.attrib = {'name':utils.format_name(self.name)} 124 | hardwareInterface_joint = SubElement(joint, 'hardwareInterface') 125 | hardwareInterface_joint.text = 'hardware_interface/EffortJointInterface' 126 | 127 | actuator = SubElement(tran, 'actuator') 128 | actuator.attrib = {'name':utils.format_name(self.name) + '_actr'} 129 | hardwareInterface_actr = SubElement(actuator, 'hardwareInterface') 130 | hardwareInterface_actr.text = 'hardware_interface/EffortJointInterface' 131 | mechanicalReduction = SubElement(actuator, 'mechanicalReduction') 132 | mechanicalReduction.text = '1' 133 | 134 | return tran 135 | 136 | class Link (Location): 137 | 138 | scale = '0.001' 139 | 140 | def __init__(self, name, xyz, rpy, center_of_mass, sub_folder, mass, inertia_tensor, bodies, sub_mesh, material_dict, visible): 141 | """ 142 | Parameters 143 | ---------- 144 | name: str 145 | name of the link 146 | xyz: [x, y, z] 147 | coordinate for the visual and collision 148 | center_of_mass: [x, y, z] 149 | coordinate for the center of mass 150 | link_xml: str 151 | generated xml describing about the link 152 | sub_folder: str 153 | the name of the repository to save the xml file 154 | mass: float 155 | mass of the link 156 | inertia_tensor: [ixx, iyy, izz, ixy, iyz, ixz] 157 | tensor of the inertia 158 | bodies = [body1, body2, body3] 159 | list of visible bodies 160 | """ 161 | 162 | super().__init__(name, xyz, rpy) 163 | # xyz for center of mass 164 | self.center_of_mass = [round_mm(x) for x in center_of_mass] 165 | self._link_xml = None 166 | self.sub_folder = sub_folder 167 | self.mass = mass 168 | self.inertia_tensor = inertia_tensor 169 | self.bodies = bodies 170 | self.sub_mesh = sub_mesh # if we want to export each body as a separate mesh 171 | self.material_dict = material_dict 172 | self.visible = visible 173 | 174 | 175 | def link_xml(self) -> Optional[Element]: 176 | """ 177 | Generate the link_xml and hold it by self.link_xml 178 | """ 179 | link = Element('link') 180 | link.attrib = {'name':self.name} 181 | rpy = ' '.join([str(_) for _ in self.rpy]) 182 | scale = ' '.join([self.scale]*3) 183 | 184 | #inertial 185 | inertial = SubElement(link, 'inertial') 186 | origin_i = SubElement(inertial, 'origin') 187 | origin_i.attrib = {'xyz':' '.join([str(_) for _ in self.center_of_mass]), 'rpy':rpy} 188 | mass = SubElement(inertial, 'mass') 189 | mass.attrib = {'value':str(self.mass)} 190 | inertia = SubElement(inertial, 'inertia') 191 | inertia.attrib = {'ixx':str(self.inertia_tensor[0]), 'iyy':str(self.inertia_tensor[1]), 192 | 'izz':str(self.inertia_tensor[2]), 'ixy':str(self.inertia_tensor[3]), 193 | 'iyz':str(self.inertia_tensor[4]), 'ixz':str(self.inertia_tensor[5])} 194 | 195 | if not self.visible: 196 | return link 197 | 198 | # visual 199 | if self.sub_mesh and len(self.bodies) > 1: # if we want to export each as a separate mesh 200 | for body_name in self.bodies: 201 | visual = SubElement(link, 'visual') 202 | origin_v = SubElement(visual, 'origin') 203 | origin_v.attrib = {'xyz':' '.join([str(_) for _ in self.xyz]), 'rpy':rpy} 204 | geometry_v = SubElement(visual, 'geometry') 205 | mesh_v = SubElement(geometry_v, 'mesh') 206 | mesh_v.attrib = {'filename':f'package://{self.sub_folder}{body_name}.stl','scale':scale} 207 | material = SubElement(visual, 'material') 208 | material.attrib = {'name': self.material_dict[body_name]} 209 | else: 210 | visual = SubElement(link, 'visual') 211 | origin_v = SubElement(visual, 'origin') 212 | origin_v.attrib = {'xyz':' '.join([str(_) for _ in self.xyz]), 'rpy':rpy} 213 | geometry_v = SubElement(visual, 'geometry') 214 | mesh_v = SubElement(geometry_v, 'mesh') 215 | mesh_v.attrib = {'filename':f'package://{self.sub_folder}{self.name}.stl','scale':scale} 216 | material = SubElement(visual, 'material') 217 | material.attrib = {'name': self.material_dict[self.name]} 218 | 219 | 220 | # collision 221 | collision = SubElement(link, 'collision') 222 | origin_c = SubElement(collision, 'origin') 223 | origin_c.attrib = {'xyz':' '.join([str(_) for _ in self.xyz]), 'rpy':rpy} 224 | geometry_c = SubElement(collision, 'geometry') 225 | mesh_c = SubElement(geometry_c, 'mesh') 226 | mesh_c.attrib = {'filename':f'package://{self.sub_folder}{self.name}.stl','scale':scale} 227 | 228 | return link -------------------------------------------------------------------------------- /Descriptor/core/manager.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from datetime import datetime 3 | import subprocess 4 | from typing import Dict, List, Optional 5 | import os 6 | import os.path 7 | import zoneinfo 8 | import yaml 9 | 10 | import adsk.core 11 | import adsk.fusion 12 | 13 | from . import parser 14 | from . import io 15 | from . import utils 16 | 17 | 18 | class Manager: 19 | ''' Manager class for setting params and generating URDF 20 | ''' 21 | 22 | root: Optional[adsk.fusion.Component] = None 23 | design: Optional[adsk.fusion.Design] = None 24 | _app: Optional[adsk.core.Application] = None 25 | 26 | def __init__(self, save_dir, robot_name, save_mesh, sub_mesh, mesh_resolution, inertia_precision, 27 | target_units, target_platform, config_file) -> None: 28 | '''Initialization of Manager class 29 | 30 | Parameters 31 | ---------- 32 | save_dir : str 33 | path to directory for storing data 34 | save_mesh : bool 35 | if mesh data should be exported 36 | mesh_resolution : str 37 | quality of mesh conversion 38 | inertia_precision : str 39 | quality of inertia calculations 40 | document_units : str 41 | base units of current file 42 | target_units : str 43 | target files units 44 | target_platform : str 45 | which configuration to use for exporting urdf 46 | 47 | ''' 48 | self.save_mesh = save_mesh 49 | self.sub_mesh = sub_mesh 50 | 51 | assert self.design is not None 52 | 53 | doc_u = 1.0 54 | if self.design.unitsManager.defaultLengthUnits=='mm': doc_u = 0.001 55 | elif self.design.unitsManager.defaultLengthUnits=='cm': doc_u = 0.01 56 | elif self.design.unitsManager.defaultLengthUnits=='m': doc_u = 1.0 57 | else: 58 | raise ValueError(f"Inexpected document units: '{self.design.unitsManager.defaultLengthUnits}'") 59 | 60 | tar_u = 1.0 61 | if target_units=='mm': tar_u = 0.001 62 | elif target_units=='cm': tar_u = 0.01 63 | elif target_units=='m': tar_u = 1.0 64 | 65 | self.scale = doc_u / tar_u 66 | self.cm = 0.01 / tar_u 67 | 68 | if inertia_precision == 'Low': 69 | self.inert_accuracy = adsk.fusion.CalculationAccuracy.LowCalculationAccuracy 70 | elif inertia_precision == 'Medium': 71 | self.inert_accuracy = adsk.fusion.CalculationAccuracy.MediumCalculationAccuracy 72 | elif inertia_precision == 'High': 73 | self.inert_accuracy = adsk.fusion.CalculationAccuracy.VeryHighCalculationAccuracy 74 | 75 | if mesh_resolution == 'Low': 76 | self.mesh_accuracy = adsk.fusion.MeshRefinementSettings.MeshRefinementLow 77 | elif mesh_resolution == 'Medium': 78 | self.mesh_accuracy = adsk.fusion.MeshRefinementSettings.MeshRefinementMedium 79 | elif mesh_resolution == 'High': 80 | self.mesh_accuracy = adsk.fusion.MeshRefinementSettings.MeshRefinementHigh 81 | 82 | # set the target platform 83 | self.target_platform = target_platform 84 | 85 | self.robot_name = robot_name 86 | 87 | self.name_map: Dict[str, str] = {} 88 | self.merge_links: Dict[str, List[str]] = {} 89 | self.extra_links: List[str] = [] 90 | self.locations: Dict[str, Dict[str,str]] = {} 91 | self.root_name: Optional[str] = None 92 | if config_file: 93 | with open(config_file, "rb") as yml: 94 | configuration = yaml.load(yml, yaml.SafeLoader) 95 | if not isinstance(configuration, dict): 96 | raise(ValueError(f"Malformed config '{config_file}': top level should be a dictionary")) 97 | wrong_keys = set(configuration).difference([ 98 | "RobotName", "SaveMesh", "SubMesh", "MeshResolution", "InertiaPrecision", 99 | "TargetUnits", "TargetPlatform", "NameMap", "MergeLinks", 100 | "Locations", "Extras", "Root", 101 | ]) 102 | if wrong_keys: 103 | raise(ValueError(f"Malformed config '{config_file}': unexpected top-level keys: {list(wrong_keys)}")) 104 | self.name_map = configuration.get("NameMap", {}) 105 | if self.name_map is None: 106 | self.name_map = {} 107 | self.merge_links = configuration.get("MergeLinks", {}) 108 | if self.merge_links is None: 109 | self.merge_links = {} 110 | self.extra_links = configuration.get("Extras", []) 111 | if self.extra_links is None: 112 | self.extra_links = [] 113 | self.locations = configuration.get("Locations", {}) 114 | if self.locations is None: 115 | self.locations = {} 116 | self.root_name = configuration.get("Root") 117 | 118 | # Set directory 119 | self._set_dir(save_dir) 120 | 121 | def _set_dir(self, save_dir): 122 | '''sets the class instance save directory 123 | 124 | Parameters 125 | ---------- 126 | save_dir : str 127 | path to save 128 | ''' 129 | # set the names 130 | package_name = self.robot_name + '_description' 131 | 132 | self.save_dir = os.path.join(save_dir, package_name) 133 | try: os.mkdir(self.save_dir) 134 | except: pass 135 | 136 | def preview(self): 137 | ''' Get all joints in the scene for previewing joints 138 | 139 | Returns 140 | ------- 141 | dict 142 | mapping of joint names with parent-> child relationship 143 | ''' 144 | assert Manager.root is not None 145 | 146 | config = parser.Configurator(Manager.root, self.scale, self.cm, self.robot_name, self.name_map, self.merge_links, self.locations, self.extra_links, self.root_name) 147 | config.inertia_accuracy = self.inert_accuracy 148 | ## Return array of tuples (parent, child) 149 | config.get_scene_configuration() 150 | return config.get_joint_preview() 151 | 152 | @staticmethod 153 | def get_git_info() -> str: 154 | my_dir = os.path.abspath(os.path.dirname(__file__)) 155 | git = ["git", "-C", my_dir] 156 | def call_git(cmd: List[str]) -> str: 157 | cmd = git + cmd 158 | out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) 159 | return out.decode("utf-8").strip() 160 | try: 161 | if call_git(['rev-parse', '--is-inside-work-tree']) != "true": 162 | return "" 163 | url = "" 164 | try: 165 | branch = call_git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']) 166 | if "/" in branch: 167 | remote = branch.split('/')[0] 168 | url = call_git(['remote', 'get-url', remote]) 169 | except subprocess.CalledProcessError: 170 | pass 171 | commit_hash = call_git(["rev-parse", "HEAD"]) 172 | commit_timestamp = call_git(["log", "-1", "--format=%ct"]) 173 | commit_author = call_git(["log", "-1", "--format=%an"]) 174 | commit_datetime = datetime.fromtimestamp(int(commit_timestamp), tz=zoneinfo.ZoneInfo("UTC")) 175 | commit_date = commit_datetime.astimezone().strftime("%A, %B %d, %Y at %I:%M %p %Z") 176 | if url: 177 | repo_name = url 178 | else: 179 | repo_name = os.path.basename(os.path.dirname(os.path.dirname(my_dir))) 180 | return f"{repo_name} rev {commit_hash} dated {commit_date} by {commit_author}" 181 | except subprocess.CalledProcessError: 182 | return "" 183 | 184 | def run(self): 185 | ''' process the scene, including writing to directory and 186 | exporting mesh, if applicable 187 | ''' 188 | assert Manager.root is not None 189 | assert Manager.design is not None 190 | 191 | utils.start_log_timer() 192 | if self._app is not None and self._app.activeViewport is not None: 193 | utils.viewport = self._app.activeViewport 194 | utils.log("*** Parsing ***") 195 | config = parser.Configurator(Manager.root, self.scale, self.cm, self.robot_name, self.name_map, self.merge_links, self.locations, self.extra_links, self.root_name) 196 | config.inertia_accuracy = self.inert_accuracy 197 | config.sub_mesh = self.sub_mesh 198 | utils.log("** Getting scene configuration **") 199 | config.get_scene_configuration() 200 | utils.log("** Parsing the configuration **") 201 | config.parse() 202 | 203 | # -------------------- 204 | # Generate URDF 205 | utils.log(f"*** Generating URDF under {os.path.realpath(self.save_dir)} ***") 206 | self.urdf_dir = os.path.join(self.save_dir,'urdf') 207 | writer = io.Writer(self.urdf_dir, config) 208 | writer.write_urdf() 209 | 210 | if config.locs: 211 | dict = {l: {loc.name: {"xyz": loc.xyz, "rpy": loc.rpy} for loc in locs} for l, locs in config.locs.items()} 212 | with open(os.path.join(self.urdf_dir, "locations.yaml"), "wt") as f: 213 | yaml.dump(dict, f, yaml.SafeDumper, default_flow_style=None, sort_keys=False, indent=3) 214 | 215 | with open(os.path.join(self.urdf_dir, "fusion2urdf.txt"), "wt") as f: 216 | git_info = self.get_git_info() 217 | if git_info: 218 | git_info = f"\n\tusing {git_info}" 219 | f.write(f"URDF structure created from Fusion Model {Manager.root.name}{git_info}:\n") 220 | for s in config.tree_str: 221 | f.write(s) 222 | f.write("\n") 223 | 224 | utils.log(f"*** Generating {self.target_platform} configuration") 225 | if self.target_platform == 'pyBullet': 226 | io.write_hello_pybullet(config.name, self.save_dir) 227 | elif self.target_platform == 'rviz': 228 | io.copy_ros2(self.save_dir, config.name) 229 | elif self.target_platform == 'Gazebo': 230 | io.copy_gazebo(self.save_dir, config.name) 231 | elif self.target_platform == 'MoveIt': 232 | io.copy_moveit(self.save_dir, config.name) 233 | 234 | 235 | # Custom STL Export 236 | if self.save_mesh: 237 | utils.log("*** Generating mesh STLs ***") 238 | io.visible_to_stl(Manager.design, self.save_dir, Manager.root, self.mesh_accuracy, self.sub_mesh, config.body_dict, Manager._app) 239 | utils.log(f"*** Done! Time elapsed: {utils.time_elapsed():.1f}s ***") 240 | if utils.all_warnings: 241 | utils.log(f"There were {len(utils.all_warnings)} warnings!\n\t" + "\n\t".join(utils.all_warnings)) 242 | utils.all_warnings = [] 243 | 244 | -------------------------------------------------------------------------------- /Descriptor/core/io.py: -------------------------------------------------------------------------------- 1 | import os.path, sys, fileinput 2 | from typing import Dict, List, Sequence, Tuple 3 | from xml.etree.ElementTree import Element, ElementTree, SubElement 4 | import xml.etree.ElementTree as ET 5 | import adsk, adsk.core, adsk.fusion 6 | 7 | from .parser import Configurator 8 | from .parts import Joint, Link 9 | from . import utils 10 | from shutil import copytree 11 | 12 | 13 | def visible_to_stl( 14 | design: adsk.fusion.Design, 15 | save_dir: str, 16 | root: adsk.fusion.Component, 17 | accuracy: adsk.fusion.MeshRefinementSettings, 18 | sub_mesh: bool, 19 | body_mapper: Dict[str, List[Tuple[adsk.fusion.BRepBody, str]]], 20 | _app, 21 | ): 22 | """ 23 | export top-level components as a single stl file into "save_dir/" 24 | 25 | Parameters 26 | ---------- 27 | design: adsk.fusion.Design 28 | fusion design document 29 | save_dir: str 30 | directory path to save 31 | root: adsk.fusion.Component 32 | root component of the design 33 | accuracy: adsk.fusion.MeshRefinementSettings enum 34 | accuracy value to use for stl export 35 | component_map: list 36 | list of all bodies to use for stl export 37 | """ 38 | 39 | # create a single exportManager instance 40 | exporter = design.exportManager 41 | 42 | newDoc: adsk.core.Document = _app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType, True) 43 | newDes = newDoc.products.itemByProductType("DesignProductType") 44 | assert isinstance(newDes, adsk.fusion.Design) 45 | newRoot = newDes.rootComponent 46 | 47 | # get the script location 48 | save_dir = os.path.join(save_dir, "meshes") 49 | try: 50 | os.mkdir(save_dir) 51 | except: 52 | pass 53 | 54 | try: 55 | for name, bodies in body_mapper.items(): 56 | if not bodies: 57 | continue 58 | 59 | # Create a new exporter in case its a memory thing 60 | exporter = design.exportManager 61 | 62 | occName = utils.format_name(name) 63 | stl_exporter(exporter, accuracy, newRoot, [b for b, _ in bodies], os.path.join(save_dir, occName)) 64 | 65 | if sub_mesh and len(bodies) > 1: 66 | for body, body_name in bodies: 67 | if body.isVisible: 68 | stl_exporter(exporter, accuracy, newRoot, [body], os.path.join(save_dir, body_name)) 69 | finally: 70 | newDoc.close(False) 71 | 72 | 73 | def stl_exporter(exportMgr, accuracy, newRoot, body_lst, filename): 74 | """Copy a component to a new document, save, then delete. 75 | 76 | Modified from solution proposed by BrianEkins https://EkinsSolutions.com 77 | 78 | Parameters 79 | ---------- 80 | exportMgr : _type_ 81 | _description_ 82 | newRoot : _type_ 83 | _description_ 84 | body_lst : _type_ 85 | _description_ 86 | filename : _type_ 87 | _description_ 88 | """ 89 | 90 | tBrep = adsk.fusion.TemporaryBRepManager.get() 91 | 92 | bf = newRoot.features.baseFeatures.add() 93 | bf.startEdit() 94 | 95 | for body in body_lst: 96 | tBody = tBrep.copy(body) 97 | newRoot.bRepBodies.add(tBody, bf) 98 | 99 | bf.finishEdit() 100 | stlOptions = exportMgr.createSTLExportOptions(newRoot, f"{filename}.stl") 101 | stlOptions.meshRefinement = accuracy 102 | exportMgr.execute(stlOptions) 103 | 104 | bf.deleteMe() 105 | 106 | 107 | class Writer: 108 | 109 | def __init__(self, save_dir: str, config: Configurator) -> None: 110 | self.save_dir = save_dir 111 | self.config = config 112 | 113 | def write_urdf(self) -> None: 114 | """Write each component of the xml structure to file 115 | 116 | Parameters 117 | ---------- 118 | save_dir : str 119 | path to save file 120 | config : Configurator 121 | root nodes instance of configurator class 122 | """ 123 | 124 | try: 125 | os.mkdir(self.save_dir) 126 | except: 127 | pass 128 | file_name = os.path.join(self.save_dir, f"{self.config.name}.xacro") # the name of urdf file 129 | if self.config.extra_links: 130 | links = self.config.links.copy() 131 | for link in self.config.extra_links: 132 | del links[link] 133 | joints = { 134 | joint: self.config.joints[joint] 135 | for joint in self.config.joints 136 | if self.config.joints[joint].child not in self.config.extra_links 137 | } 138 | self.write_xacro(file_name, links, joints) 139 | file_name_full = os.path.join(self.save_dir, f"{self.config.name}-full.xacro") 140 | self.write_xacro(file_name_full, {link: self.config.links[link] for link in self.config.extra_links}, {}) 141 | else: 142 | self.write_xacro(file_name, self.config.links, self.config.joints) 143 | 144 | material_file_name = os.path.join(self.save_dir, f"materials.xacro") 145 | self.write_materials_xacro(material_file_name) 146 | 147 | def write_materials_xacro(self, material_file_name) -> None: 148 | robot = Element("robot", {"name": self.config.name, "xmlns:xacro": "http://www.ros.org/wiki/xacro"}) 149 | for color in self.config.color_dict: 150 | material = SubElement(robot, "material", {"name": color}) 151 | SubElement(material, "color", {"rgba": self.config.color_dict[color]}) 152 | 153 | tree = ElementTree(robot) 154 | ET.indent(tree, space=" ") 155 | 156 | with open(material_file_name, mode="wb") as f: 157 | tree.write(f, "utf-8", xml_declaration=True) 158 | f.write(b"\n") 159 | 160 | def write_xacro(self, file_name: str, links: Dict[str, Link], joints: Dict[str, Joint]) -> None: 161 | robot = Element("robot", {"xmlns:xacro": "http://www.ros.org/wiki/xacro", "name": self.config.name}) 162 | SubElement(robot, "xacro:include", {"filename": f"$(find {self.config.name})/urdf/materials.xacro"}) 163 | 164 | # Add dummy link since KDL does not support a root link with an inertia 165 | # From https://robotics.stackexchange.com/a/97510 166 | SubElement(robot, "link", {"name": "dummy_link"}) 167 | assert self.config.base_link is not None 168 | dummy_joint = SubElement(robot, "joint", {"name": "dummy_link_joint", "type": "fixed"}) 169 | SubElement(dummy_joint, "parent", {"link": "dummy_link"}) 170 | SubElement(dummy_joint, "child", {"link": self.config.base_link_name}) 171 | 172 | for _, link in links.items(): 173 | xml = link.link_xml() 174 | if xml is not None: 175 | robot.append(xml) 176 | 177 | for _, joint in joints.items(): 178 | robot.append(joint.joint_xml()) 179 | 180 | tree = ElementTree(robot) 181 | ET.indent(tree, space=" ") 182 | 183 | with open(file_name, mode="wb") as f: 184 | tree.write(f, "utf-8", xml_declaration=True) 185 | f.write(b"\n") 186 | 187 | 188 | def write_hello_pybullet(robot_name, save_dir) -> None: 189 | """Writes a sample script which loads the URDF in pybullet 190 | 191 | Modified from https://github.com/yanshil/Fusion2PyBullet 192 | 193 | Parameters 194 | ---------- 195 | robot_name : str 196 | name to use for directory 197 | save_dir : str 198 | path to store file 199 | """ 200 | 201 | robot_urdf = f"{robot_name}.urdf" ## basename of robot.urdf 202 | file_name = os.path.join(save_dir, "hello_bullet.py") 203 | hello_pybullet = """ 204 | import pybullet as p 205 | import os 206 | import time 207 | import pybullet_data 208 | physicsClient = p.connect(p.GUI)#or p.DIRECT for non-graphical version 209 | p.setAdditionalSearchPath(pybullet_data.getDataPath()) #optionally 210 | p.setGravity(0,0,-10) 211 | planeId = p.loadURDF("plane.urdf") 212 | cubeStartPos = [0,0,0] 213 | cubeStartOrientation = p.getQuaternionFromEuler([0,0,0]) 214 | dir = os.path.abspath(os.path.dirname(__file__)) 215 | robot_urdf = "TEMPLATE.urdf" 216 | dir = os.path.join(dir,'urdf') 217 | robot_urdf=os.path.join(dir,robot_urdf) 218 | robotId = p.loadURDF(robot_urdf,cubeStartPos, cubeStartOrientation, 219 | # useMaximalCoordinates=1, ## New feature in Pybullet 220 | flags=p.URDF_USE_INERTIA_FROM_FILE) 221 | for i in range (10000): 222 | p.stepSimulation() 223 | time.sleep(1./240.) 224 | cubePos, cubeOrn = p.getBasePositionAndOrientation(robotId) 225 | print(cubePos,cubeOrn) 226 | p.disconnect() 227 | """ 228 | hello_pybullet = hello_pybullet.replace("TEMPLATE.urdf", robot_urdf) 229 | with open(file_name, mode="w") as f: 230 | f.write(hello_pybullet) 231 | f.write("\n") 232 | 233 | 234 | def copy_ros2(save_dir, package_name) -> None: 235 | # Use current directory to find `package_ros2` 236 | package_ros2_path = os.path.dirname(os.path.abspath(os.path.dirname(__file__))) + "/package_ros2/" 237 | copy_package(save_dir, package_ros2_path) 238 | update_cmakelists(save_dir, package_name) 239 | update_package_xml(save_dir, package_name) 240 | update_package_name(save_dir + "/launch/robot_description.launch.py", package_name) 241 | 242 | 243 | def copy_gazebo(save_dir, package_name) -> None: 244 | # Use current directory to find `gazebo_package` 245 | gazebo_package_path = os.path.dirname(os.path.abspath(os.path.dirname(__file__))) + "/gazebo_package/" 246 | copy_package(save_dir, gazebo_package_path) 247 | update_cmakelists(save_dir, package_name) 248 | update_package_xml(save_dir, package_name) 249 | update_package_name(save_dir + "/launch/robot_description.launch.py", package_name) # Also include rviz alone 250 | update_package_name(save_dir + "/launch/gazebo.launch.py", package_name) 251 | 252 | 253 | def copy_moveit(save_dir, package_name) -> None: 254 | # Use current directory to find `moveit_package` 255 | moveit_package_path = os.path.dirname(os.path.abspath(os.path.dirname(__file__))) + "/moveit_package/" 256 | copy_package(save_dir, moveit_package_path) 257 | update_cmakelists(save_dir, package_name) 258 | update_package_xml(save_dir, package_name) 259 | update_package_name(save_dir + "/launch/setup_assistant.launch.py", package_name) 260 | 261 | 262 | def copy_package(save_dir, package_dir) -> None: 263 | try: 264 | os.mkdir(save_dir + "/launch") 265 | except: 266 | pass 267 | try: 268 | os.mkdir(save_dir + "/urdf") 269 | except: 270 | pass 271 | copytree(package_dir, save_dir, dirs_exist_ok=True) 272 | 273 | 274 | def update_cmakelists(save_dir, package_name) -> None: 275 | file_name = save_dir + "/CMakeLists.txt" 276 | 277 | for line in fileinput.input(file_name, inplace=True): 278 | if "project(fusion2urdf)" in line: 279 | sys.stdout.write("project(" + package_name + ")\n") 280 | else: 281 | sys.stdout.write(line) 282 | 283 | 284 | def update_package_name(file_name, package_name) -> None: 285 | # Replace 'fusion2urdf' with the package_name 286 | for line in fileinput.input(file_name, inplace=True): 287 | if "fusion2urdf" in line: 288 | sys.stdout.write(line.replace("fusion2urdf", package_name)) 289 | else: 290 | sys.stdout.write(line) 291 | 292 | 293 | def update_package_xml(save_dir, package_name) -> None: 294 | file_name = save_dir + "/package.xml" 295 | 296 | for line in fileinput.input(file_name, inplace=True): 297 | if "" in line: 298 | sys.stdout.write(" " + package_name + "\n") 299 | elif "" in line: 300 | sys.stdout.write("The " + package_name + " package\n") 301 | else: 302 | sys.stdout.write(line) 303 | -------------------------------------------------------------------------------- /Descriptor/core/ui.py: -------------------------------------------------------------------------------- 1 | ''' module: user interface''' 2 | 3 | from typing import Optional 4 | import adsk.core, adsk.fusion, traceback 5 | 6 | try: 7 | from yaml import SafeLoader # Test whether it's there 8 | _ = SafeLoader # Noop for import to not be unused 9 | from scipy.spatial.transform import Rotation # Test whether it's there 10 | _ = Rotation # Noop for import to not be unused 11 | except ModuleNotFoundError: 12 | import sys 13 | import subprocess 14 | import os.path 15 | exec = sys.executable 16 | if "python" not in os.path.basename(sys.executable).lower(): 17 | # There is a crazy thing on Mac, where sys.executable is Fusion iteself, not Python :( 18 | exec = subprocess.__file__ 19 | for i in range(3): 20 | exec = os.path.dirname(exec) 21 | exec = os.path.join(exec, "bin", "python") 22 | subprocess.check_call([exec, '-m', 'ensurepip']) 23 | subprocess.check_call([exec, '-m', 'pip', 'install', 'scipy', 'pyyaml']) 24 | 25 | from . import utils 26 | from . import manager 27 | 28 | def save_dir_dialog(ui: adsk.core.UserInterface) -> Optional[str]: 29 | '''display the dialog to pick the save directory 30 | 31 | Parameters 32 | ---------- 33 | ui : [type] 34 | [description] 35 | 36 | Returns 37 | ------- 38 | [type] 39 | [description] 40 | ''' 41 | 42 | # Set styles of folder dialog. 43 | folderDlg = ui.createFolderDialog() 44 | folderDlg.title = 'URDF Save Folder Dialog' 45 | 46 | # Show folder dialog 47 | dlgResult = folderDlg.showDialog() 48 | if dlgResult == adsk.core.DialogResults.DialogOK: 49 | return folderDlg.folder 50 | return None 51 | 52 | def yaml_file_dialog(ui: adsk.core.UserInterface) -> Optional[str]: 53 | '''display the dialog to pick a yaml file 54 | 55 | Parameters 56 | ---------- 57 | ui : [type] 58 | [description] 59 | 60 | Returns 61 | ------- 62 | [type] 63 | [description] 64 | ''' 65 | 66 | # Set styles of folder dialog. 67 | fileDlg = ui.createFileDialog() 68 | fileDlg.filter = "Configuration File (*.yaml)" 69 | fileDlg.title = 'Configuration File Dialog' 70 | fileDlg.isMultiSelectEnabled = False 71 | 72 | # Show folder dialog 73 | dlgResult = fileDlg.showOpen() 74 | if dlgResult == adsk.core.DialogResults.DialogOK: 75 | return fileDlg.filename 76 | return None 77 | 78 | class MyInputChangedHandler(adsk.core.InputChangedEventHandler): 79 | def __init__(self, ui: adsk.core.UserInterface): 80 | self.ui = ui 81 | super().__init__() 82 | 83 | def notify(self, eventArgs: adsk.core.InputChangedEventArgs) -> None: 84 | try: 85 | cmd = eventArgs.firingEvent.sender 86 | assert isinstance(cmd, adsk.core.Command) 87 | inputs = cmd.commandInputs 88 | cmdInput = eventArgs.input 89 | 90 | # Get settings of UI 91 | directory_path = inputs.itemById('directory_path') 92 | config = inputs.itemById('config') 93 | robot_name = inputs.itemById('robot_name') 94 | save_mesh = inputs.itemById('save_mesh') 95 | sub_mesh = inputs.itemById('sub_mesh') 96 | mesh_resolution = inputs.itemById('mesh_resolution') 97 | inertia_precision = inputs.itemById('inertia_precision') 98 | target_units = inputs.itemById('target_units') 99 | target_platform = inputs.itemById('target_platform') 100 | preview_group = inputs.itemById('preview_group') 101 | 102 | utils.log(f"DEBUG: UI: processing command: {cmdInput.id}") 103 | 104 | assert isinstance(directory_path, adsk.core.TextBoxCommandInput) 105 | assert isinstance(config, adsk.core.TextBoxCommandInput) 106 | assert isinstance(robot_name, adsk.core.TextBoxCommandInput) 107 | assert isinstance(save_mesh, adsk.core.BoolValueCommandInput) 108 | assert isinstance(sub_mesh, adsk.core.BoolValueCommandInput) 109 | assert isinstance(mesh_resolution, adsk.core.DropDownCommandInput) 110 | assert isinstance(inertia_precision, adsk.core.DropDownCommandInput) 111 | assert isinstance(target_units, adsk.core.DropDownCommandInput) 112 | assert isinstance(target_platform, adsk.core.DropDownCommandInput) 113 | assert isinstance(preview_group, adsk.core.GroupCommandInput) 114 | 115 | if cmdInput.id == 'generate': 116 | # User asked to generate using current settings 117 | # print(f'{directory_path.text}, {save_mesh.value}, {mesh_resolution.selectedItem.name},\ 118 | # {inertia_precision.selectedItem.name},\ 119 | # {target_units.selectedItem.name} ) 120 | 121 | document_manager = manager.Manager(directory_path.text, robot_name.text, 122 | save_mesh.value, sub_mesh.value, 123 | mesh_resolution.selectedItem.name, 124 | inertia_precision.selectedItem.name, 125 | target_units.selectedItem.name, 126 | target_platform.selectedItem.name, 127 | config.text) 128 | 129 | # Generate 130 | document_manager.run() 131 | 132 | elif cmdInput.id == 'preview': 133 | # Generate Hierarchy and Preview in panel 134 | document_manager = manager.Manager(directory_path.text, robot_name.text, save_mesh.value, sub_mesh.value, mesh_resolution.selectedItem.name, 135 | inertia_precision.selectedItem.name, target_units.selectedItem.name, 136 | target_platform.selectedItem.name, config.text) 137 | # # Generate 138 | _joints = document_manager.preview() 139 | 140 | joints_text = inputs.itemById('jointlist') 141 | assert isinstance(joints_text, adsk.core.TextBoxCommandInput) 142 | 143 | _txt = 'joint name: parent link-> child link\n' 144 | 145 | for k, j in _joints.items(): 146 | _txt += f'{k} : {j.parent} -> {j.child}\n' 147 | joints_text.text = _txt 148 | preview_group.isExpanded = True 149 | 150 | elif cmdInput.id == 'save_dir': 151 | # User set the save directory 152 | config_file = save_dir_dialog(self.ui) 153 | if config_file is not None: 154 | directory_path.text = config_file 155 | directory_path.numRows = 2 156 | 157 | elif cmdInput.id == 'set_config': 158 | # User set the save directory 159 | config_file = yaml_file_dialog(self.ui) 160 | if config_file is not None: 161 | try: 162 | import yaml 163 | with open(config_file, "rb") as yml: 164 | configuration = yaml.load(yml, yaml.SafeLoader) 165 | if 'RobotName' in configuration: 166 | if not isinstance(configuration['RobotName'], str): 167 | raise ValueError ("RobotName should be a string") 168 | robot_name.text = configuration['RobotName'] 169 | if 'SaveMesh' in configuration: 170 | if not isinstance(configuration['SaveMesh'], bool): 171 | raise ValueError ("SaveMesh should be a boolean") 172 | save_mesh.value = configuration['SaveMesh'] 173 | if 'SubMesh' in configuration: 174 | if not isinstance(configuration['SubMesh'], bool): 175 | raise ValueError ("SubMesh should be a boolean") 176 | sub_mesh.value = configuration['SubMesh'] 177 | for key, selector in [ 178 | ('MeshResolution', mesh_resolution), 179 | ('InertiaPrecision', inertia_precision), 180 | ('TargetUnits', target_units), 181 | ('TargetPlatform', target_platform), 182 | ]: 183 | if key in configuration: 184 | names = [item.name for item in selector.listItems] 185 | if configuration[key] not in names: 186 | raise ValueError (f"{key} should be one of {names}") 187 | for item in selector.listItems: 188 | item.isSelected = (item.name == configuration[key]) 189 | config.text = config_file 190 | config.numRows = 2 191 | except Exception as e: 192 | utils.log(f"ERROR: error loading configuration file {config_file}: {e}") 193 | except: 194 | if self.ui: 195 | self.ui.messageBox('Failed:\n{}'.format(traceback.format_exc())) 196 | 197 | class MyDestroyHandler(adsk.core.CommandEventHandler): 198 | def __init__(self, ui): 199 | self.ui = ui 200 | super().__init__() 201 | def notify(self, eventArgs: adsk.core.CommandEventArgs) -> None: 202 | try: 203 | adsk.terminate() 204 | except: 205 | if self.ui: 206 | self.ui.messageBox('Failed:\n{}'.format(traceback.format_exc())) 207 | 208 | class MyCreatedHandler(adsk.core.CommandCreatedEventHandler): 209 | '''[summary] 210 | 211 | Parameters 212 | ---------- 213 | adsk : adsk.core.CommandCreatedEventHandler 214 | Main handler for callbacks 215 | ''' 216 | def __init__(self, ui: adsk.core.UserInterface, handlers): 217 | '''[summary] 218 | 219 | Parameters 220 | ---------- 221 | ui : adsk.core.UserInterface 222 | main variable to interact with autodesk 223 | handlers : list 224 | list to keep reference to UI handler 225 | ''' 226 | self.ui = ui 227 | self.handlers = handlers 228 | super().__init__() 229 | 230 | def notify(self, eventArgs: adsk.core.CommandCreatedEventArgs) -> None: 231 | ''' Construct the GUI and set aliases to be referenced later 232 | 233 | Parameters 234 | ---------- 235 | args : adsk.core.CommandCreatedEventArgs 236 | UI information 237 | ''' 238 | try: 239 | cmd = eventArgs.command 240 | onDestroy = MyDestroyHandler(self.ui) 241 | cmd.destroy.add(onDestroy) 242 | 243 | onInputChanged = MyInputChangedHandler(self.ui) 244 | cmd.inputChanged.add(onInputChanged) 245 | 246 | self.handlers.append(onDestroy) 247 | self.handlers.append(onInputChanged) 248 | inputs = cmd.commandInputs 249 | 250 | assert manager.Manager.root is not None 251 | 252 | # Show path to save 253 | directory_path = inputs.addTextBoxCommandInput('directory_path', 'Save Directory', 'C:', 2, True) 254 | # Button to set the save directory 255 | btn = inputs.addBoolValueInput('save_dir', 'Set Save Directory', False) 256 | btn.isFullWidth = True 257 | 258 | config_file = inputs.addTextBoxCommandInput('config', 'Configuration File (Optional)', '', 2, True) 259 | # Button to set the save directory 260 | btn = inputs.addBoolValueInput('set_config', 'Select Configuration File', False) 261 | btn.isFullWidth = True 262 | 263 | inputs.addTextBoxCommandInput('robot_name', 'Robot Name', manager.Manager.root.name.split()[0], 1, False) 264 | 265 | # Add checkbox to generate/export the mesh or not 266 | inputs.addBoolValueInput('save_mesh', 'Save Mesh', True) 267 | 268 | # Add checkbox to generate/export sub meshes or not 269 | inputs.addBoolValueInput('sub_mesh', 'Per-Body Visual Mesh', True) 270 | 271 | # Add dropdown to determine mesh export resolution 272 | di = inputs.addDropDownCommandInput('mesh_resolution', 'Mesh Resolution', adsk.core.DropDownStyles.TextListDropDownStyle) 273 | di = di.listItems 274 | di.add('Low', True, '') 275 | di.add('Medium', False, '') 276 | di.add('High', False, '') 277 | 278 | # Add dropdown to determine inertia calculation resolution 279 | di = inputs.addDropDownCommandInput('inertia_precision', 'Inertia Precision', adsk.core.DropDownStyles.TextListDropDownStyle) 280 | di = di.listItems 281 | di.add('Low', True, '') 282 | di.add('Medium', False, '') 283 | di.add('High', False, '') 284 | 285 | # Add dropdown to set target export units 286 | di = inputs.addDropDownCommandInput('target_units', 'Target Units', adsk.core.DropDownStyles.TextListDropDownStyle) 287 | di = di.listItems 288 | di.add('mm', False, '') 289 | di.add('cm', False, '') 290 | di.add('m', True, '') 291 | 292 | # Set the type of platform to target for building XML 293 | di = inputs.addDropDownCommandInput('target_platform', 'Target Platform', adsk.core.DropDownStyles.TextListDropDownStyle) 294 | di = di.listItems 295 | di.add('None', True, '') 296 | di.add('pyBullet', False, '') 297 | di.add('rviz', False, '') 298 | di.add('Gazebo', False, '') 299 | di.add('MoveIt', False, '') 300 | 301 | # Make a button to preview the hierarchy 302 | btn = inputs.addBoolValueInput('preview', 'Preview Links', False) 303 | btn.isFullWidth = True 304 | 305 | # Create tab input 306 | tab_input = inputs.addTabCommandInput('tab_preview', 'Preview Tabs') 307 | tab_input_child = tab_input.children 308 | # Create group 309 | preview_group = tab_input_child.addGroupCommandInput("preview_group", "Preview") 310 | preview_group.isExpanded = False 311 | textbox_group = preview_group.children 312 | 313 | # Create a textbox. 314 | txtbox = textbox_group.addTextBoxCommandInput('jointlist', 'Joint List', '', 8, True) 315 | txtbox.isFullWidth = True 316 | 317 | # Make a button specifically to generate based on current settings 318 | btn = inputs.addBoolValueInput('generate', 'Generate', False) 319 | btn.isFullWidth = True 320 | 321 | cmd.setDialogSize(500,0) 322 | 323 | # After setDialogSize to accomodate longer dir and config paths, set them to 1 row each 324 | directory_path.numRows = 1 325 | config_file.numRows = 1 326 | 327 | except: 328 | if self.ui: 329 | self.ui.messageBox('Failed:\n{}'.format(traceback.format_exc())) 330 | 331 | 332 | def config_settings(ui: adsk.core.UserInterface, ui_handlers) -> bool: 333 | '''[summary] 334 | 335 | Parameters 336 | ---------- 337 | ui : adsk.core.UserInterface 338 | part of the autodesk UI 339 | ui_handlers : List 340 | empty list to hold reference 341 | 342 | Returns 343 | ------- 344 | bool 345 | success or failure 346 | ''' 347 | 348 | try: 349 | commandId = 'Joint Configuration Descriptor' 350 | commandDescription = 'Settings to describe a URDF file' 351 | commandName = 'URDF Description App' 352 | 353 | cmdDef = ui.commandDefinitions.itemById(commandId) 354 | if not cmdDef: 355 | cmdDef = ui.commandDefinitions.addButtonDefinition(commandId, commandName, commandDescription) 356 | 357 | onCommandCreated = MyCreatedHandler(ui, ui_handlers) 358 | cmdDef.commandCreated.add(onCommandCreated) 359 | ui_handlers.append(onCommandCreated) 360 | 361 | cmdDef.execute() 362 | 363 | adsk.autoTerminate(False) 364 | 365 | return True 366 | 367 | except: 368 | exn = traceback.format_exc() 369 | utils.log(f"FATAL: {exn}") 370 | if ui: 371 | ui.messageBox(f'Failed:\n{exn}') 372 | 373 | return False 374 | -------------------------------------------------------------------------------- /Descriptor/core/parser.py: -------------------------------------------------------------------------------- 1 | ''' 2 | module to parse fusion file 3 | ''' 4 | 5 | from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple, Union 6 | from dataclasses import dataclass, field 7 | 8 | import adsk.core, adsk.fusion 9 | import numpy as np 10 | from . import transforms 11 | from . import parts 12 | from . import utils 13 | from collections import OrderedDict, defaultdict 14 | 15 | @dataclass(frozen=True, kw_only=True, eq=False) 16 | class JointInfo: 17 | name: str 18 | parent: str 19 | child: str 20 | type: str = "fixed" 21 | origin: adsk.core.Vector3D = field(default_factory=adsk.core.Vector3D.create) 22 | axis: adsk.core.Vector3D = field(default_factory=adsk.core.Vector3D.create) 23 | upper_limit: float = 0.0 24 | lower_limit: float = 0.0 25 | 26 | class Hierarchy: 27 | ''' hierarchy of the design space ''' 28 | 29 | total_components = 0 30 | 31 | def __init__(self, component) -> None: 32 | ''' Initialize Hierarchy class to parse document and define component relationships. 33 | Uses a recursive traversal (based off of fusion example) and provides helper functions 34 | to get specific children and parents for nodes. 35 | Parameters 36 | ---------- 37 | component : [type] 38 | fusions root component to use for traversal 39 | ''' 40 | 41 | self.children: List["Hierarchy"] = [] 42 | self.component: adsk.fusion.Occurrence = component 43 | self.name: str = component.name 44 | Hierarchy.total_components += 1 45 | if utils.LOG_DEBUG: 46 | utils.log(f"... {Hierarchy.total_components}. Collected {self.name}...") 47 | 48 | def _add_child(self, c: "Hierarchy") -> None: 49 | self.children.append(c) 50 | 51 | def get_children(self) -> List["Hierarchy"]: 52 | return self.children 53 | 54 | def get_all_children(self) -> Dict[str, "Hierarchy"]: 55 | ''' get all children and sub children of this instance ''' 56 | 57 | child_map = OrderedDict() 58 | parent_stack: List["Hierarchy"] = [] 59 | parent_stack += self.get_children() 60 | while parent_stack: 61 | # Pop an element form the stack (order shouldn't matter) 62 | tmp = parent_stack.pop(0) 63 | # Add this child to the map 64 | # use the entity token, more accurate than the name of the component (since there are multiple) 65 | child_map[tmp.component.entityToken] = tmp 66 | parent_stack += tmp.get_children() 67 | return child_map 68 | 69 | def get_flat_body(self) -> List[adsk.fusion.BRepBody]: 70 | ''' get a flat list of all components and child components ''' 71 | 72 | child_list = [] 73 | body_list: List[List[adsk.fusion.BRepBody]] = [] 74 | 75 | child_set = list(self.get_all_children().values()) 76 | 77 | if len(child_set) == 0: 78 | body_list.append(list(self.component.bRepBodies)) 79 | 80 | child_list = [x.children for x in child_set if len(x.children)>0] 81 | parent_stack : List[Hierarchy] = [] 82 | for c in child_list: 83 | for _c in c: 84 | parent_stack.append(_c) 85 | 86 | closed_set = set() 87 | 88 | while len(parent_stack) != 0: 89 | # Pop an element form the stack (order shouldn't matter) 90 | tmp = parent_stack.pop() 91 | closed_set.add(tmp) 92 | # Get any bodies directly associated with this component 93 | if tmp.component.bRepBodies.count > 0: 94 | body_list.append(list(tmp.component.bRepBodies)) 95 | 96 | # Check if this child has children 97 | if len(tmp.children)> 0: 98 | # add them to the parent_stack 99 | child_set = list(self.get_all_children().values()) 100 | 101 | child_list = [x.children for x in child_set if len(x.children)>0] 102 | for c in child_list: 103 | for _c in c: 104 | if _c not in closed_set: 105 | parent_stack.append(_c) 106 | 107 | flat_bodies: List[adsk.fusion.BRepBody] = [] 108 | for body in body_list: 109 | flat_bodies.extend(body) 110 | 111 | return flat_bodies 112 | 113 | @staticmethod 114 | def traverse(occurrences: adsk.fusion.OccurrenceList, parent: Optional["Hierarchy"] = None) -> "Hierarchy": 115 | '''Recursively create class instances and define a parent->child structure 116 | Based on the fusion 360 API docs 117 | 118 | Parameters 119 | ---------- 120 | occurrences : [type] 121 | [description] 122 | parent : [type], optional 123 | [description], by default None 124 | Returns 125 | ------- 126 | Hierarchy 127 | Instance of the class 128 | ''' 129 | 130 | assert occurrences 131 | for i in range(0, occurrences.count): 132 | occ = occurrences.item(i) 133 | 134 | cur = Hierarchy(occ) 135 | 136 | if parent is None: 137 | pass 138 | else: 139 | parent._add_child(cur) 140 | 141 | if occ.childOccurrences: 142 | Hierarchy.traverse(occ.childOccurrences, parent=cur) 143 | return cur # type: ignore[undef] 144 | 145 | def get_origin(o: Optional[adsk.core.Base]) -> Union[adsk.core.Vector3D, None]: 146 | if isinstance(o, adsk.fusion.JointGeometry): 147 | return o.origin.asVector() 148 | elif o is None: 149 | return None 150 | elif isinstance(o, adsk.fusion.JointOrigin): 151 | return get_origin(o.geometry) 152 | else: 153 | utils.fatal(f"parser.get_origin: unexpected {o} of type {type(o)}") 154 | 155 | def get_context_name(c: Optional[adsk.fusion.Occurrence]) -> str: 156 | return c.name if c is not None else 'ROOT level' 157 | 158 | def getMatrixFromRoot(occ: Optional[adsk.fusion.Occurrence]) -> adsk.core.Matrix3D: 159 | """ 160 | Given an occurence, return its coordinate transform w.r.t the global frame 161 | This is inspired by https://forums.autodesk.com/t5/fusion-api-and-scripts/how-to-get-the-joint-origin-in-world-context/m-p/10051818/highlight/true#M12545, 162 | but somehow this is completely unneeded (and results in incorrect values) as .transform2 is already 163 | always in global frame, despite what the documentation says!!! 164 | """ 165 | mat = adsk.core.Matrix3D.create() 166 | while occ is not None: 167 | mat.transformBy(occ.transform2) 168 | occ = occ.assemblyContext 169 | return mat 170 | 171 | class Configurator: 172 | 173 | # Map to URDF type 174 | joint_types: Dict[adsk.fusion.JointTypes, str] = { 175 | adsk.fusion.JointTypes.RigidJointType: "fixed", 176 | adsk.fusion.JointTypes.RevoluteJointType: "revolute", 177 | adsk.fusion.JointTypes.SliderJointType: "prismatic", 178 | adsk.fusion.JointTypes.CylindricalJointType: "Cylindrical_unsupported", 179 | adsk.fusion.JointTypes.PinSlotJointType: "PinSlot_unsupported", 180 | adsk.fusion.JointTypes.PlanarJointType: "planar", 181 | adsk.fusion.JointTypes.BallJointType: "Ball_unsupported", 182 | } 183 | 184 | def __init__(self, root: adsk.fusion.Component, scale: float, cm: float, name: str, name_map: Dict[str, str], merge_links: Dict[str, List[str]], locations: Dict[str, Dict[str, str]], extra_links: Sequence[str], root_name: Optional[str]) -> None: 185 | ''' Initializes Configurator class to handle building hierarchy and parsing 186 | Parameters 187 | ---------- 188 | root : [type] 189 | root component of design document 190 | ''' 191 | # Export top-level occurrences 192 | self.root = root 193 | self.occ = root.occurrences.asList 194 | self.inertia_accuracy = adsk.fusion.CalculationAccuracy.LowCalculationAccuracy 195 | 196 | self.sub_mesh = False 197 | self.links_by_token: Dict[str, str] = OrderedDict() 198 | self.links_by_name : Dict[str, adsk.fusion.Occurrence] = OrderedDict() 199 | self.joints_dict: Dict[str, JointInfo] = OrderedDict() 200 | self.body_dict: Dict[str, List[Tuple[adsk.fusion.BRepBody, str]]] = OrderedDict() 201 | self.material_dict: Dict[str, str] = OrderedDict() 202 | self.color_dict: Dict[str, str] = OrderedDict() 203 | self.links: Dict[str, parts.Link] = OrderedDict() # Link class 204 | self.joints: Dict[str, parts.Joint] = OrderedDict() # Joint class for writing to file 205 | self.locs: Dict[str, List[parts.Location]] = OrderedDict() 206 | self.scale = scale # Convert autodesk units to meters (or whatever simulator takes) 207 | self.cm = cm # Convert cm units to meters (or whatever simulator takes) 208 | parts.Link.scale = str(self.scale) 209 | self.eps = 1e-7 / self.scale 210 | self.base_link: Optional[adsk.fusion.Occurrence] = None 211 | self.component_map: Dict[str, Hierarchy] = OrderedDict() # Entity tokens for each component 212 | self.bodies_collected: Set[str] = set() # For later sanity checking - all bodies passed to URDF 213 | self.name_map = name_map 214 | self.merge_links = merge_links 215 | self.locations = locations 216 | self.extra_links = extra_links 217 | 218 | self.root_node: Optional[Hierarchy] = None 219 | self.root_name = root_name 220 | 221 | self.name = name 222 | self.mesh_folder = f'{name}/meshes/' 223 | 224 | def close_enough(self, a, b) -> bool: 225 | if isinstance(a, float) and isinstance(b, float): 226 | return abs(a-b) < self.eps 227 | elif isinstance(a, list) and isinstance(b, list): 228 | assert len(a) == len(b) 229 | return all((self.close_enough(aa,bb) for aa,bb in zip(a,b))) 230 | elif isinstance(a, tuple) and isinstance(b, tuple): 231 | assert len(a) == len(b) 232 | return all((self.close_enough(aa,bb) for aa,bb in zip(a,b))) 233 | elif isinstance(a, adsk.core.Vector3D) and isinstance(b, adsk.core.Vector3D): 234 | return self.close_enough(a.asArray(), b.asArray()) 235 | elif isinstance(a, adsk.core.Point3D) and isinstance(b, adsk.core.Point3D): 236 | return self.close_enough(a.asArray(), b.asArray()) 237 | elif isinstance(a, adsk.core.Matrix3D) and isinstance(b, adsk.core.Matrix3D): 238 | return self.close_enough(a.asArray(), b.asArray()) 239 | else: 240 | utils.fatal(f"parser.Configurator.close_enough: {type(a)} and {type(b)}: not supported") 241 | 242 | def get_scene_configuration(self): 243 | '''Build the graph of how the scene components are related 244 | ''' 245 | Hierarchy.total_components = 0 246 | utils.log("* Traversing the hierarchy *") 247 | self.root_node = Hierarchy(self.root) 248 | occ_list=self.root.occurrences.asList 249 | Hierarchy.traverse(occ_list, self.root_node) 250 | utils.log(f"* Collected {Hierarchy.total_components} components, processing *") 251 | self.component_map = self.root_node.get_all_children() 252 | utils.log("* Processing sub-bodies *") 253 | self.get_sub_bodies() 254 | 255 | return self.component_map 256 | 257 | 258 | def get_sub_bodies(self) -> None: 259 | ''' temp fix for ensuring that a top-level component is associated with bodies''' 260 | 261 | # write the immediate children of root node 262 | self.body_mapper: Dict[str, List[adsk.fusion.BRepBody]] = defaultdict(list) 263 | 264 | assert self.root_node is not None 265 | 266 | # for k,v in self.component_map.items(): 267 | for v in self.root_node.children: 268 | 269 | children = set() 270 | children.update(v.children) 271 | 272 | top_level_body = [x for x in v.component.bRepBodies if x.isVisible] 273 | 274 | # add to the body mapper 275 | if top_level_body != []: 276 | self.body_mapper[v.component.entityToken].extend(top_level_body) 277 | 278 | while children: 279 | cur = children.pop() 280 | children.update(cur.children) 281 | sub_level_body = [x for x in cur.component.bRepBodies if x.isVisible] 282 | 283 | # add to this body mapper again 284 | self.body_mapper[cur.component.entityToken].extend(sub_level_body) 285 | 286 | def get_joint_preview(self) -> Dict[str, JointInfo]: 287 | ''' Get the scenes joint relationships without calculating links 288 | Returns 289 | ------- 290 | dict 291 | joint relationships 292 | ''' 293 | 294 | self._joints() 295 | return self.joints_dict 296 | 297 | def parse(self): 298 | ''' parse the scene by building up inertia and joints''' 299 | 300 | self._base() 301 | self._joints() 302 | self._links() 303 | self._materials() 304 | self._build() 305 | 306 | def _base(self): 307 | ''' Get the base link ''' 308 | if self.root_name is not None and self.root_name in self.merge_links: 309 | self.base_link = self._resolve_name(self.merge_links[self.root_name][0]) 310 | else: 311 | for oc in self._iterate_through_occurrences(): 312 | # Get only the first grounded link 313 | if ( 314 | (self.root_name is None and oc.isGrounded) or 315 | (self.root_name is not None and self.root_name == oc.name) 316 | ): 317 | # We must store this object because we cannot occurrences 318 | self.base_link = oc 319 | break 320 | if self.base_link is None: 321 | if self.root_name is None: 322 | utils.fatal("Failed to find a grounded occurrence for URDF root. Make one of the Fusion occurrences grounded or specify 'Root: name' in the configuration file") 323 | else: 324 | utils.fatal(f"Occurrence '{self.root_name}' specified in the 'Root:' section of the configuration file not found in the design") 325 | self.get_name(self.base_link) 326 | 327 | def get_name(self, oc: adsk.fusion.Occurrence) -> str: 328 | if oc.entityToken in self.links_by_token: 329 | return self.links_by_token[oc.entityToken] 330 | name = utils.rename_if_duplicate(self.name_map.get(oc.name, oc.name), self.links_by_name) 331 | self.links_by_name[name] = oc 332 | self.links_by_token[oc.entityToken] = name 333 | utils.log(f"DEBUG: link '{oc.name}' ('{oc.fullPathName}') became '{name}'") 334 | return name 335 | 336 | def _get_inertia(self, oc: adsk.fusion.Occurrence): 337 | occs_dict = {} 338 | 339 | prop = oc.getPhysicalProperties(self.inertia_accuracy) 340 | 341 | mass = prop.mass # kg 342 | 343 | # Iterate through bodies, only add mass of bodies that are visible (lightbulb) 344 | body_lst = self.component_map[oc.entityToken].get_flat_body() 345 | 346 | if len(body_lst) > 0: 347 | for body in body_lst: 348 | # Check if this body is hidden 349 | # 350 | # body = oc.bRepBodies.item(i) 351 | if not body.isVisible: 352 | mass -= body.physicalProperties.mass 353 | 354 | occs_dict['mass'] = mass 355 | 356 | center_of_mass = prop.centerOfMass.copy() 357 | 358 | # transform, cm -> m 359 | transform = self.link_origins[self.links_by_token[oc.entityToken]].copy() 360 | assert transform.invert() 361 | c_o_m = center_of_mass.copy() 362 | assert c_o_m.transformBy(transform) 363 | # It is in cm, not in design units. 364 | occs_dict['center_of_mass'] = [c * self.cm for c in c_o_m.asArray()] 365 | 366 | utils.log( 367 | f"DEBUG: {oc.name}: origin={utils.vector_to_str(oc.transform2.translation)}," 368 | f" center_mass(global)={utils.vector_to_str(prop.centerOfMass)}," 369 | f" center_mass(URDF)={occs_dict['center_of_mass']}") 370 | 371 | moments = prop.getXYZMomentsOfInertia() 372 | if not moments[0]: 373 | utils.fatal(f"Retrieving moments of inertia for {oc.name} failed") 374 | 375 | # https://help.autodesk.com/view/fusion360/ENU/?guid=GUID-ce341ee6-4490-11e5-b25b-f8b156d7cd97 376 | occs_dict['inertia'] = [_ * self.cm * self.cm for _ in transforms.origin2center_of_mass(moments[1:], prop.centerOfMass.asArray(), mass) ] ## kg / cm^2 -> kg/m^2 377 | 378 | return occs_dict 379 | 380 | def _iterate_through_occurrences(self) -> Iterable[adsk.fusion.Occurrence]: 381 | for token in self.component_map.values(): 382 | yield token.component 383 | 384 | 385 | def _joints(self): 386 | ''' Iterates over joints list and defines properties for each joint 387 | (along with its relationship) 388 | ''' 389 | 390 | for joint in self.root.allJoints: 391 | if joint.healthState in [adsk.fusion.FeatureHealthStates.SuppressedFeatureHealthState, adsk.fusion.FeatureHealthStates.RolledBackFeatureHealthState]: 392 | continue 393 | try: 394 | if isinstance(joint.jointMotion, adsk.fusion.RevoluteJointMotion): 395 | if joint.jointMotion.rotationValue != 0.0: 396 | utils.log(f"WARNING: joint {joint.name} was not at 0, rotating it to 0") 397 | joint.jointMotion.rotationValue = 0.0 398 | elif isinstance(joint.jointMotion, adsk.fusion.SliderJointMotion): 399 | if joint.jointMotion.slideValue != 0.0: 400 | utils.log(f"WARNING: joint {joint.name} was not at 0, sliding it to 0") 401 | joint.jointMotion.slideValue = 0.0 402 | except Exception as e: 403 | try: 404 | o1 = joint.occurrenceOne.fullPathName 405 | except: 406 | o1 = "Unknown" 407 | try: 408 | o2 = joint.occurrenceTwo.fullPathName 409 | except: 410 | o2 = "Unknown" 411 | utils.fatal( 412 | f"Fusion errored out trying to operate on `jointMotion` of joint {joint.name}" 413 | f" (between {o1} and {o2}, child of {joint.parentComponent.name}) with Health State {joint.healthState}: {e}") 414 | 415 | for joint in sorted(self.root.allJoints, key=lambda joint: joint.name): 416 | if joint.healthState in [adsk.fusion.FeatureHealthStates.SuppressedFeatureHealthState, adsk.fusion.FeatureHealthStates.RolledBackFeatureHealthState]: 417 | utils.log(f"Skipping joint {joint.name} (child of {joint.parentComponent.name}) as it is suppressed or rolled back") 418 | continue 419 | 420 | if joint.healthState != adsk.fusion.FeatureHealthStates.HealthyFeatureHealthState: 421 | try: 422 | o1 = joint.occurrenceOne.fullPathName 423 | except: 424 | o1 = "Unknown" 425 | try: 426 | o2 = joint.occurrenceTwo.fullPathName 427 | except: 428 | o2 = "Unknown" 429 | utils.fatal(f"Joint {joint.name} (between {o1} and {o2}, child of {joint.parentComponent.name}) is in unexpected Health State {joint.healthState}, {joint.errorOrWarningMessage=}") 430 | 431 | orig_name = joint.name 432 | # Rename if the joint already exists in our dictionary 433 | try: 434 | _ = joint.entityToken # Just making sure it exists 435 | 436 | joint_type = Configurator.joint_types[joint.jointMotion.jointType] 437 | 438 | occ_one = joint.occurrenceOne 439 | occ_two = joint.occurrenceTwo 440 | except RuntimeError as e: 441 | utils.log(f"WARNING: Failed to process joint {joint.name} (child of {joint.parentComponent.name}): {e}, {joint.isValid=}. This is likely a Fusion bug - the joint was likely deleted, but somehow we still see it. Will ignore it.") 442 | continue 443 | 444 | if occ_one is None or occ_two is None: 445 | utils.log(f"WARNING: Failed to process joint {joint.name} (child of {joint.parentComponent.name}): {joint.isValid=}: occ_one is {None if occ_one is None else occ_one.name}, occ_two is {None if occ_two is None else occ_two.name}") 446 | continue 447 | 448 | name = utils.rename_if_duplicate(self.name_map.get(joint.name, joint.name), self.joints_dict) 449 | 450 | parent = self.get_name(occ_one) 451 | child = self.get_name(occ_two) 452 | 453 | if utils.LOG_DEBUG: 454 | utils.log(f"... Processing joint {orig_name}->{name} of type {joint_type}, between {occ_one.name}->{parent} and {occ_two.name}->{child}") 455 | 456 | utils.log(f"DEBUG: Got from Fusion: {joint_type} {name} connecting") 457 | utils.log(f"DEBUG: ... {parent} @ {occ_one.transform2.translation.asArray()} and") 458 | utils.log(f"DEBUG: ... {child} @ {occ_two.transform2.translation.asArray()}") 459 | 460 | if joint_type == "fixed": 461 | info = JointInfo(name=name, child=child, parent=parent) 462 | 463 | else: 464 | try: 465 | geom_one_origin = get_origin(joint.geometryOrOriginOne) 466 | except RuntimeError: 467 | geom_one_origin = None 468 | try: 469 | geom_two_origin = get_origin(joint.geometryOrOriginTwo) 470 | except RuntimeError: 471 | geom_two_origin = None 472 | 473 | utils.log(f"DEBUG: ... Origin 1: {utils.vector_to_str(geom_one_origin) if geom_one_origin is not None else None}") 474 | utils.log(f"DEBUG: ... Origin 2: {utils.vector_to_str(geom_two_origin) if geom_two_origin is not None else None}") 475 | 476 | if occ_one.assemblyContext != occ_two.assemblyContext: 477 | utils.log(f"DEBUG: Non-fixed joint {name} crosses the assembly context boundary:" 478 | f" {parent} is in {get_context_name(occ_one.assemblyContext)}" 479 | f" but {child} is in {get_context_name(occ_two.assemblyContext)}") 480 | 481 | if geom_one_origin is None: 482 | utils.fatal(f'Non-fixed joint {orig_name} does not have an origin, aborting') 483 | elif geom_two_origin is not None and not self.close_enough(geom_two_origin, geom_one_origin): 484 | utils.log(f'WARNING: Occurrences {occ_one.name} and {occ_two.name} of non-fixed {orig_name}' + 485 | f' have origins {geom_one_origin.asArray()} and {geom_two_origin.asArray()}' 486 | f' that do not coincide.') 487 | 488 | # Only Revolute joints have rotation axis 489 | if isinstance(joint.jointMotion, adsk.fusion.RevoluteJointMotion): 490 | assert joint.jointMotion.rotationLimits.isMaximumValueEnabled 491 | assert joint.jointMotion.rotationLimits.isMinimumValueEnabled 492 | joint_vector = joint.jointMotion.rotationAxisVector 493 | # The values are in radians per 494 | # https://help.autodesk.com/view/fusion360/ENU/?guid=GUID-e3fb19a1-d7ef-4b34-a6f5-76a907d6a774 495 | joint_limit_max = joint.jointMotion.rotationLimits.maximumValue 496 | joint_limit_min = joint.jointMotion.rotationLimits.minimumValue 497 | 498 | if abs(joint_limit_max - joint_limit_min) == 0: 499 | # Rotation is unlimited 500 | joint_limit_min = -3.14159 501 | joint_limit_max = 3.14159 502 | elif isinstance(joint.jointMotion, adsk.fusion.SliderJointMotion): 503 | assert joint.jointMotion.slideLimits.isMaximumValueEnabled 504 | assert joint.jointMotion.slideLimits.isMinimumValueEnabled 505 | joint_vector=joint.jointMotion.slideDirectionVector 506 | 507 | # The values are in cm per 508 | # https://help.autodesk.com/view/fusion360/ENU/?guid=GUID-e3fb19a1-d7ef-4b34-a6f5-76a907d6a774 509 | joint_limit_max = joint.jointMotion.slideLimits.maximumValue * self.cm 510 | joint_limit_min = joint.jointMotion.slideLimits.minimumValue * self.cm 511 | else: 512 | # Keep default limits for 'RigidJointMotion' or others 513 | joint_vector = adsk.core.Vector3D.create() 514 | joint_limit_max = 0.0 515 | joint_limit_min = 0.0 516 | 517 | info = JointInfo( 518 | name=name, child=child, parent=parent, origin=geom_one_origin, type=joint_type, 519 | axis=joint_vector, upper_limit=joint_limit_max, lower_limit=joint_limit_min) 520 | 521 | self.joints_dict[name] = info 522 | 523 | # Add RigidGroups as fixed joints 524 | for group in sorted(self.root.allRigidGroups, key=lambda group: group.name): 525 | original_group_name = group.name 526 | try: 527 | if group.isSuppressed: 528 | utils.log(f"WARNING: Skipping suppressed rigid group {original_group_name} (child of {group.parentComponent.name})") 529 | continue 530 | if not group.isValid: 531 | utils.log(f"WARNING: skipping invalid rigid group {original_group_name} (child of {group.parentComponent.name})") 532 | continue 533 | except RuntimeError as e: 534 | utils.log(f"WARNING: skipping invalid rigid group {original_group_name}: (child of {group.parentComponent.name}) {e}") 535 | continue 536 | utils.log(f"DEBUG: Processing rigid group {original_group_name}: {[(occ.name if occ else None) for occ in group.occurrences]}") 537 | parent_occ: Optional[adsk.fusion.Occurrence] = None 538 | for occ in group.occurrences: 539 | if occ is None: 540 | continue 541 | elif parent_occ is None: 542 | # Assumes that the first occurrence will be the parent 543 | parent_occ = occ 544 | continue 545 | rigid_group_occ_name = utils.rename_if_duplicate(original_group_name, self.joints_dict) 546 | 547 | parent_occ_name = self.get_name(parent_occ) # type: ignore[undef] 548 | occ_name = self.get_name(occ) 549 | utils.log( 550 | f"DEBUG: Got from Fusion: {rigid_group_occ_name}, connecting", 551 | f"parent {parent_occ_name} @ {utils.vector_to_str(parent_occ.transform2.translation)} and" # type: ignore[undef] 552 | f"child {occ_name} {utils.vector_to_str(occ.transform2.translation)}") 553 | self.joints_dict[rigid_group_occ_name] = JointInfo(name=rigid_group_occ_name, parent=parent_occ_name, child=occ_name) 554 | 555 | self.assembly_tokens: Set[str] = set() 556 | for occ in self.root.allOccurrences: 557 | if occ.childOccurrences.count > 0: 558 | # It's an assembly 559 | self.assembly_tokens.add(occ.entityToken) 560 | 561 | def get_assembly_links(self, occ: adsk.fusion.Occurrence, parent_included: bool) -> List[str]: 562 | result: List[str] = [] 563 | for child in occ.childOccurrences: 564 | child_included = child.entityToken in self.links_by_token 565 | if child_included: 566 | result.append(self.links_by_token[child.entityToken]) 567 | child_included = parent_included or child_included 568 | if child.entityToken in self.assembly_tokens: 569 | result += self.get_assembly_links(child, child_included) 570 | elif not child_included: 571 | result.append(self.get_name(child)) 572 | utils.log(f"DEBUG: get_assembly_links({occ.name}) = {result}") 573 | return result 574 | 575 | @staticmethod 576 | def _mk_pattern(name: str) -> Union[str, Tuple[str,str]]: 577 | c = name.count("*") 578 | if c > 1: 579 | utils.fatal(f"Occurrance name pattern '{name}' is invalid: only one '*' is supported") 580 | if c: 581 | pref, suff = name.split("*", 1) 582 | return (pref, suff) 583 | return name 584 | 585 | @staticmethod 586 | def _match(candidate: str, pattern: Union[str, Tuple[str,str]]) -> bool: 587 | if isinstance(pattern, str): 588 | return candidate == pattern 589 | pref, suff = pattern 590 | return len(candidate) >= len(pref) + len(suff) and candidate.startswith(pref) and candidate.endswith(suff) 591 | 592 | def _resolve_name(self, name:str) -> adsk.fusion.Occurrence: 593 | if "+" in name: 594 | name_parts = name.split("+") 595 | l = len(name_parts) 596 | patts = [self._mk_pattern(p) for p in name_parts] 597 | candidate: Optional[adsk.fusion.Occurrence]= None 598 | for occ in self._iterate_through_occurrences(): 599 | path = occ.fullPathName.split("+") 600 | if len(path) < l: 601 | continue 602 | mismatch = False 603 | for (cand, patt) in zip(path[-l:], patts): 604 | if not self._match(cand, patt): 605 | mismatch = True 606 | break 607 | if mismatch: 608 | continue 609 | if candidate is None: 610 | candidate = occ 611 | else: 612 | utils.fatal(f"Name/pattern '{name}' in configuration file matches at least two occurrences: '{candidate.fullPathName}' and '{occ.fullPathName}', update to be more specific") 613 | if not candidate: 614 | utils.fatal(f"Name/pattern '{name}' in configuration file does not match any occurrences") 615 | return candidate 616 | patt = self._mk_pattern(name) 617 | candidates = [occ for occ in self._iterate_through_occurrences() if self._match(occ.name, patt)] 618 | if not candidates: 619 | utils.fatal(f"Name/pattern '{name}' in configuration file does not match any occurrences") 620 | if len(candidates) > 1: 621 | utils.fatal(f"Name/pattern '{name}' in configuration file matches at least two occurrences: '{candidates[0].fullPathName}' and '{candidates[1].fullPathName}', update to be more specific") 622 | return candidates[0] 623 | 624 | def _links(self): 625 | self.merged_links_by_link: Dict[str, Tuple[str, List[str], List[adsk.fusion.Occurrence]]] = OrderedDict() 626 | self.merged_links_by_name: Dict[str, Tuple[str, List[str], List[adsk.fusion.Occurrence]]] = OrderedDict() 627 | 628 | for name, names in self.merge_links.items(): 629 | if not names: 630 | utils.fatal(f"Invalid MergeLinks YAML config setting: merged link '{name}' is empty, which is not allowed") 631 | link_names = [] 632 | for n in names: 633 | occ = self._resolve_name(n) 634 | if occ.entityToken in self.links_by_token or occ.entityToken in self.assembly_tokens: 635 | link_names.append(self.get_name(occ)) 636 | if occ.entityToken in self.assembly_tokens: 637 | try: 638 | link_names += self.get_assembly_links(occ, occ.entityToken in self.links_by_token) 639 | except ValueError as e: 640 | utils.fatal(f"Invalid MergeLinks YAML config setting: assembly '{n}' for merged link '{name}' could not be processed: {e.args[0]}") 641 | if name in self.links_by_name and name not in names: 642 | utils.fatal(f"Invalid MergeLinks YAML config setting: merged '{name}' clashes with existing Fusion link '{self.links_by_name[name].fullPathName}'; add the latter to NameMap in YAML to avoid the name clash") 643 | link_names = list(OrderedDict.fromkeys(link_names)) # Remove duplicates 644 | val = name, link_names, [self.links_by_name[n] for n in link_names] 645 | utils.log(f"Merged link {name} <- occurrences {link_names}") 646 | self.merged_links_by_name[name] = val 647 | for link_name in link_names: 648 | if link_name in self.merged_links_by_link: 649 | utils.fatal(f"Invalid MergeLinks YAML config setting: {link_name} is included in two merged links: '{name}' and '{self.merged_links_by_link[link_name][0]}'") 650 | self.merged_links_by_link[link_name] = val 651 | 652 | body_names: Dict[str, Tuple[()]] = OrderedDict() 653 | 654 | renames = set(self.name_map) 655 | 656 | for oc in self._iterate_through_occurrences(): 657 | renames.difference_update([oc.name]) 658 | occ_name, _, occs = self._get_merge(oc) 659 | if occ_name in self.body_dict: 660 | continue 661 | 662 | oc_name = utils.format_name(occ_name) 663 | self.body_dict[occ_name] = [] 664 | bodies = set() 665 | 666 | for sub_oc in occs: 667 | sub_oc_name = utils.format_name(self.get_name(sub_oc)) 668 | if sub_oc_name != oc_name: 669 | sub_oc_name = f"{oc_name}__{sub_oc_name}" 670 | for body in self.body_mapper[sub_oc.entityToken]: 671 | # Check if this body is hidden 672 | if body.isVisible and body.entityToken not in bodies: 673 | body_name = f"{sub_oc_name}__{utils.format_name(body.name)}" 674 | unique_bodyname = utils.rename_if_duplicate(body_name, body_names) 675 | body_names[unique_bodyname] = () 676 | self.body_dict[occ_name].append((body, unique_bodyname)) 677 | bodies.add(body.entityToken) 678 | 679 | if renames: 680 | ValueError("Invalid NameMap YAML config setting: some of the links are not in Fusion: '" + "', '".join(renames) + "'") 681 | 682 | def __add_link(self, name: str, occs: List[adsk.fusion.Occurrence]): 683 | urdf_origin = self.link_origins[name] 684 | inv = urdf_origin.copy() 685 | assert inv.invert() 686 | #fusion_origin = occ.transform2.translation.asArray() 687 | 688 | mass = 0.0 689 | visible = False 690 | center_of_mass = np.zeros(3) 691 | inertia_tensor = np.zeros(6) 692 | for occ in occs: 693 | inertia = self._get_inertia(occ) 694 | utils.log(f"DEBUG: link {occ.name} urdf_origin at {utils.vector_to_str(urdf_origin.translation)}" 695 | f" (rpy={utils.rpy_to_str(transforms.so3_to_euler(urdf_origin))})" 696 | f" and inv at {utils.vector_to_str(inv.translation)} (rpy={utils.rpy_to_str(transforms.so3_to_euler(inv))})") 697 | mass += inertia['mass'] 698 | visible += visible or occ.isVisible 699 | center_of_mass += np.array(inertia['center_of_mass']) * inertia['mass'] 700 | inertia_tensor += np.array(inertia['inertia']) 701 | if len(occs) == 1: 702 | inertia_tensor = inertia['inertia'] # type: ignore 703 | center_of_mass = inertia['center_of_mass'] # type: ignore 704 | else: 705 | inertia_tensor = list(inertia_tensor) 706 | center_of_mass = list(center_of_mass/mass) 707 | 708 | self.bodies_collected.update(body.entityToken for body, _ in self.body_dict[name]) 709 | 710 | self.links[name] = parts.Link(name = utils.format_name(name), 711 | xyz = (u * self.cm for u in inv.translation.asArray()), 712 | rpy = transforms.so3_to_euler(inv), 713 | center_of_mass = center_of_mass, 714 | sub_folder = self.mesh_folder, 715 | mass = mass, 716 | inertia_tensor = inertia_tensor, 717 | bodies = [body_name for _, body_name in self.body_dict[name]], 718 | sub_mesh = self.sub_mesh, 719 | material_dict = self.material_dict, 720 | visible = visible) 721 | 722 | def __get_material(self, appearance: Optional[adsk.core.Appearance]) -> str: 723 | # Material should always have an appearance, but just in case 724 | if appearance is not None: 725 | # Only supports one appearance per occurrence so return the first 726 | for prop in appearance.appearanceProperties: 727 | if type(prop) == adsk.core.ColorProperty: 728 | prop_name = appearance.name 729 | color_name = utils.convert_german(prop_name) 730 | color_name = utils.format_name(color_name) 731 | self.color_dict[color_name] = f"{prop.value.red/255} {prop.value.green/255} {prop.value.blue/255} {prop.value.opacity/255}" 732 | return color_name 733 | return "silver_default" 734 | 735 | def _materials(self) -> None: 736 | # Adapted from SpaceMaster85/fusion2urdf 737 | self.color_dict['silver_default'] = "0.700 0.700 0.700 1.000" 738 | 739 | if self.sub_mesh: 740 | for occ_name, bodies in self.body_dict.items(): 741 | if len(bodies) > 1: 742 | for body, body_name in bodies: 743 | self.material_dict[body_name] = self.__get_material(body.appearance) 744 | else: 745 | appearance = self.__get_material(bodies[0][0].appearance) if bodies else 'silver_default' 746 | self.material_dict[utils.format_name(occ_name)] = appearance 747 | else: 748 | for occ in self.links_by_name.values(): 749 | occ_name, _, occs = self._get_merge(occ) 750 | occ = occs[0] 751 | # Prioritize appearance properties, but it could be null 752 | appearance = None 753 | if occ.appearance: 754 | appearance = occ.appearance 755 | elif occ.bRepBodies: 756 | for body in occ.bRepBodies: 757 | if body.appearance: 758 | appearance = body.appearance 759 | break 760 | elif occ.component.material: 761 | appearance = occ.component.material.appearance 762 | self.material_dict[utils.format_name(occ_name)] = self.__get_material(appearance) 763 | 764 | def _get_merge(self, occ: adsk.fusion.Occurrence) -> Tuple[str, List[str], List[adsk.fusion.Occurrence]]: 765 | name = self.get_name(occ) 766 | if name in self.merged_links_by_link: 767 | return self.merged_links_by_link[name] 768 | return name, [name], [occ] 769 | 770 | def _build(self) -> None: 771 | ''' create links and joints by setting parent and child relationships and constructing 772 | the XML formats to be exported later''' 773 | 774 | # Location and XYZ of the URDF link origin w.r.t Fusion global frame in Fusion units 775 | self.link_origins: Dict[str, adsk.core.Matrix3D] = {} 776 | 777 | occurrences: Dict[str, List[str]] = OrderedDict() 778 | for joint_name, joint_info in self.joints_dict.items(): 779 | occurrences.setdefault(joint_info.parent, []) 780 | occurrences.setdefault(joint_info.child, []) 781 | occurrences[joint_info.parent].append(joint_name) 782 | occurrences[joint_info.child].append(joint_name) 783 | for link_name, joints in occurrences.items(): 784 | utils.log(f"DEBUG: {link_name} touches joints {joints}") 785 | assert self.base_link is not None 786 | self.base_link_name, base_link_names, base_link_occs = self._get_merge(self.base_link) 787 | grounded_occ = set(base_link_names) 788 | for name in [self.base_link_name] + base_link_names: 789 | # URDF origin at base link origin "by definition" 790 | self.link_origins[name] = base_link_occs[0].transform2 791 | self.__add_link(self.base_link_name, base_link_occs) 792 | boundary = grounded_occ.copy() 793 | fixed_links: Dict[Tuple[str,str], str] = {} 794 | while boundary: 795 | new_boundary : Set[str] = set() 796 | for occ_name in boundary: 797 | for joint_name in occurrences.get(occ_name, ()): 798 | joint = self.joints_dict[joint_name] 799 | if joint.parent == occ_name: 800 | child_name = joint.child 801 | if child_name in grounded_occ: 802 | continue 803 | flip_axis = True 804 | else: 805 | assert joint.child == occ_name 806 | if joint.parent in grounded_occ: 807 | continue 808 | # Parent is further away from base_link than the child, swap them 809 | child_name = joint.parent 810 | flip_axis = False 811 | 812 | 813 | parent_name, _, _ = self._get_merge(self.links_by_name[occ_name]) 814 | child_name, child_link_names, child_link_occs = self._get_merge(self.links_by_name[child_name]) 815 | 816 | child_origin = child_link_occs[0].transform2 817 | parent_origin = self.link_origins[parent_name] 818 | 819 | if utils.LOG_DEBUG and self.close_enough(parent_origin.getAsCoordinateSystem()[1:], adsk.core.Matrix3D.create().getAsCoordinateSystem()[1:]) and not self.close_enough(child_origin.getAsCoordinateSystem()[1:], adsk.core.Matrix3D.create().getAsCoordinateSystem()[1:]): 820 | utils.log(f"***** !!!!! rotating off the global frame's orientation") 821 | utils.log(f" Child axis: {[v.asArray() for v in child_origin.getAsCoordinateSystem()[1:]]}") 822 | 823 | t = parent_origin.copy() 824 | assert t.invert() 825 | 826 | axis = joint.axis 827 | 828 | if joint.type == "fixed": 829 | fixed_links[(child_name, parent_name)] = joint.name 830 | fixed_links[(parent_name, child_name)] = joint.name 831 | else: 832 | utils.log(f"DEBUG: for non-fixed joint {joint.name}, updating child origin from {utils.ct_to_str(child_origin)} to {joint.origin.asArray()}") 833 | child_origin = child_origin.copy() 834 | child_origin.translation = joint.origin 835 | # The joint axis is specified in the joint (==child) frame 836 | tt = child_origin.copy() 837 | tt.translation = adsk.core.Vector3D.create() 838 | assert tt.invert() 839 | axis = axis.copy() 840 | assert axis.transformBy(tt) 841 | if flip_axis: 842 | assert axis.scaleBy(-1) 843 | utils.log(f"DEBUG: and using {utils.ct_to_str(tt)} and {flip_axis=} to update axis from {joint.axis.asArray()} to {axis.asArray()}") 844 | 845 | for name in [child_name] + child_link_names: 846 | self.link_origins[name] = child_origin 847 | 848 | ct = child_origin.copy() 849 | assert ct.transformBy(t) 850 | 851 | xyz = [c * self.cm for c in ct.translation.asArray()] 852 | rpy = transforms.so3_to_euler(ct) 853 | 854 | utils.log( 855 | f"DEBUG: joint {joint.name} (type {joint.type})" 856 | f" from {parent_name} at {utils.vector_to_str(parent_origin.translation)}" 857 | f" to {child_name} at {utils.vector_to_str(child_origin.translation)}" 858 | f" -> xyz={utils.vector_to_str(xyz,5)} rpy={utils.rpy_to_str(rpy)}") 859 | 860 | self.joints[joint.name] = parts.Joint(name=joint.name , joint_type=joint.type, 861 | xyz=xyz, rpy=rpy, axis=axis.asArray(), 862 | parent=parent_name, child=child_name, 863 | upper_limit=joint.upper_limit, lower_limit=joint.lower_limit) 864 | 865 | self.__add_link(child_name, child_link_occs) 866 | new_boundary.update(child_link_names) 867 | grounded_occ.update(child_link_names) 868 | 869 | boundary = new_boundary 870 | 871 | disconnected_external = [] 872 | for name in self.extra_links: 873 | if name in self.merge_links: 874 | name2, _, occs = self.merged_links_by_name[name] 875 | if name2 == self.base_link_name: 876 | utils.fatal(f"Link '{name2}' is the root link, but declared as an extra (that is, not a part of the main URDF)") 877 | for oc in occs: 878 | self.link_origins[self.get_name(oc)] = occs[0].transform2 879 | else: 880 | if name == self.base_link_name: 881 | utils.fatal(f"Link '{name}' is the root link, but declared as an extra (that is, not a part of the main URDF)") 882 | elif name not in self.links_by_name: 883 | utils.fatal(f"Link '{name}' from the 'Extras:' section of the configuration file is not known") 884 | occs = [self.links_by_name[name]] 885 | self.link_origins[name] = occs[0].transform2 886 | self.__add_link(name, occs) 887 | disconnected_external.append(name) 888 | 889 | 890 | joint_children: Dict[str, List[parts.Joint]] = OrderedDict() 891 | for joint in self.joints.values(): 892 | joint_children.setdefault(joint.parent, []) 893 | joint_children[joint.parent].append(joint) 894 | tree_str = [] 895 | def get_tree(level: int, link_name: str, exclude: Set[str]): 896 | extra = f" {self.merge_links[link_name]}" if link_name in self.merge_links else "" 897 | tree_str.append(" "*level + f" - Link: {link_name}{extra}") 898 | for j in joint_children.get(link_name, ()): 899 | if j.child not in exclude: 900 | tree_str.append(" " * (level + 1) + f" - Joint [{j.type}]: {j.name}") 901 | get_tree(level+2, j.child, exclude) 902 | get_tree(1, self.base_link_name, set(disconnected_external)) 903 | if disconnected_external: 904 | tree_str.append(" - \"Extras\" links:") 905 | for extra in disconnected_external: 906 | get_tree(2, extra, set(self.link_origins).difference(disconnected_external)) 907 | 908 | # Sanity checks 909 | not_in_joints = set() 910 | unreachable = set() 911 | for occ in self._iterate_through_occurrences(): 912 | if occ.isVisible and self.body_dict.get(self.name) is not None: 913 | if occ.fullPathName not in self.links_by_token: 914 | not_in_joints.add(occ.fullPathName) 915 | elif self.links_by_token[occ.fullPathName] not in grounded_occ: 916 | unreachable.add(occ.fullPathName) 917 | for occ in self.root.allOccurrences: 918 | if any (b.isVisible and not b.entityToken in self.bodies_collected for b in occ.bRepBodies): 919 | unreachable.add(occ.fullPathName) 920 | if not_in_joints or unreachable: 921 | error = "FATAL ERROR: Not all occurrences were included in the export:" 922 | if not_in_joints: 923 | error += "Not a part of any joint or rigid group: " + ", ".join(not_in_joints) + "." 924 | if unreachable: 925 | error += "Unreacheable from the grounded occurrence via joints+links: " + ", ".join(unreachable) + "." 926 | utils.log(error) 927 | missing_joints = set(self.joints_dict).difference(self.joints) 928 | for joint_name in missing_joints.copy(): 929 | joint = self.joints_dict[joint_name] 930 | if joint.type == "fixed": 931 | parent_name, _, _ = self._get_merge(self.links_by_name[joint.parent]) 932 | child_name, _, _ = self._get_merge(self.links_by_name[joint.child]) 933 | if parent_name == child_name: 934 | utils.log(f"DEBUG: Skipped Fixed Joint '{joint_name}' that is internal for merged link {self.merged_links_by_link[joint.parent][0]}") 935 | missing_joints.remove(joint_name) 936 | elif (parent_name, child_name) in fixed_links: 937 | utils.log(f"DEBUG: Skipped Fixed Joint '{joint_name}' that is duplicative of `{fixed_links[(parent_name, child_name)]}") 938 | missing_joints.remove(joint_name) 939 | if missing_joints: 940 | utils.log("\n\t".join(["FATAL ERROR: Lost joints: "] + [f"{self.joints_dict[joint].name} of type {self.joints_dict[joint].type} between {self.joints_dict[joint].parent} and {self.joints_dict[joint].child}" for joint in missing_joints])) 941 | extra_joints = set(self.joints).difference(self.joints_dict) 942 | if extra_joints: 943 | utils.log("FATAL ERROR: Extra joints: '" + "', '".join(sorted(extra_joints)) + "'") 944 | if not_in_joints or unreachable or missing_joints or extra_joints: 945 | utils.log("Reachable from the root:") 946 | utils.log("\n".join(tree_str)) 947 | utils.fatal("Fusion structure is broken or misunderstoon by the exporter, giving up! See the full output in Text Commands console for more information.") 948 | self.tree_str = tree_str 949 | 950 | for link, locations in self.locations.items(): 951 | if link not in self.link_origins: 952 | utils.fatal(f"Link {link} specified in the config file 'Locations:' section does not exist. Make sure to use the URDF name (e.g. as set via MergeLink) rather than the Fusion one") 953 | origin = self.link_origins[link] 954 | t = origin.copy() 955 | assert t.invert() 956 | self.locs[link] = [] 957 | for loc_name, loc_occurrence in locations.items(): 958 | if loc_occurrence in self.merge_links: 959 | ct = self.link_origins[loc_occurrence].copy() 960 | else: 961 | ct = self._resolve_name(loc_occurrence).transform2.copy() 962 | assert ct.transformBy(t) 963 | self.locs[link].append(parts.Location(loc_name, [c * self.cm for c in ct.translation.asArray()], rpy = transforms.so3_to_euler(ct))) 964 | --------------------------------------------------------------------------------