├── 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 |
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 |
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 |
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 |
49 |
50 | - Click on **Descriptor** under **My Scripts** then click on **Run**. The GUI will appear.
51 |
52 |
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 |
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 |
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 |
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 |
77 |
78 | - Now, you should have a 3D shape categorized under **Bodies** on the left side.
79 |
80 |
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 |
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 |
90 |
91 | - Right click on **Bodies** and click on *Create Components from Bodies*. Both bodies should now be **Components**.
92 |
93 |
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 |
99 |
100 | - Now, go to the **Surface** tab and click on **Joint**.
101 |
102 |
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 |
108 |
109 | - Click on the green + to add the script into Fusion.
110 |
111 |
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 |
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 |
--------------------------------------------------------------------------------