diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..1b2e1b63c4 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,44 @@ +{ + "name": "moveit2_tutorials_devcontainer", + "dockerComposeFile": ["../.docker/docker-compose.yml"], + "service": "gpu", + "shutdownAction": "stopCompose", + "workspaceFolder": "/root/ws_moveit", + "remoteUser": "root", + "mounts": [ + "source=/tmp/.X11-unix,target=/tmp/.X11-unix,type=bind,consistency=cached", + "source=${localEnv:HOME}/.Xauthority,target=/home/root/.Xauthority,type=bind", + "source=${localWorkspaceFolder},target=/root/ws_moveit/src/moveit2_tutorials,type=bind,consistency=cached" + ], + "remoteEnv": { + "ROS_DISTRO": "jazzy" + }, + "containerEnv": { + "RCUTILS_COLORIZED_OUTPUT": "1", + "RCUTILS_CONSOLE_OUTPUT_FORMAT": "[{severity} {time}] [{name}]: {message} ({function_name}() at {file_name}:{line_number})", + "DISPLAY": "${localEnv:DISPLAY}", + "LIBGL_ALWAYS_SOFTWARE": "1" + }, + "initializeCommand": "bash .devcontainer/initializeCommand.sh", + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.autopep8", + "skellock.just", + "cheshirekow.cmake-format", + "ms-vscode.cmake-tools", + "redhat.vscode-xml", + "redhat.vscode-yaml", + "llvm-vs-code-extensions.vscode-clangd", + "lextudio.restructuredtext-pack", + "bbenoist.Doxygen", + "ms-vscode.live-server", + "esbenp.prettier-vscode", + "EditorConfig.EditorConfig", + "DavidAnson.vscode-markdownlint", + "mine.cpplint" + ] + } + } +} diff --git a/.devcontainer/initializeCommand.sh b/.devcontainer/initializeCommand.sh new file mode 100644 index 0000000000..1b1091d923 --- /dev/null +++ b/.devcontainer/initializeCommand.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +xhost +local:root >/dev/null 2>&1 diff --git a/.docker/docker-compose.yml b/.docker/docker-compose.yml index a0f1b3e2c2..d1de774f8b 100644 --- a/.docker/docker-compose.yml +++ b/.docker/docker-compose.yml @@ -1,28 +1,31 @@ # Example command: -# Humble on a NVIDIA system: -# DOCKER_IMAGE=humble-humble-tutorial-source docker compose run gpu # Rolling without discrete graphics: # DOCKER_IMAGE=main-rolling-tutorial-source compose run cpu +x-common: &common-service + #image: moveit/moveit2:main-jazzy-tutorial-source + image: d130s/ros:moveit2-main-jazzy-tutorial-source # TEMPORARY dev + privileged: true + network_mode: host + command: /bin/bash + tty: true + volumes: + - /tmp/.X11-unix:/tmp/.X11-unix + - $XAUTHORITY:/root/.Xauthority + environment: + &common-env + QT_X11_NO_MITSHM: 1 + DISPLAY: $DISPLAY + services: cpu: - image: moveit/moveit2:${DOCKER_IMAGE} - container_name: moveit2_container - privileged: true - network_mode: host - command: /bin/bash - volumes: - - /tmp/.X11-unix:/tmp/.X11-unix - - $XAUTHORITY:/root/.Xauthority - environment: - QT_X11_NO_MITSHM: 1 - DISPLAY: $DISPLAY + <<: *common-service + container_name: moveit2_container_cpu + profiles: + - cpu gpu: - image: moveit/moveit2:${DOCKER_IMAGE} - container_name: moveit2_container - privileged: true - network_mode: host - command: /bin/bash + <<: *common-service + container_name: moveit2_container_gpu deploy: resources: reservations: @@ -30,11 +33,7 @@ services: - driver: nvidia count: 1 capabilities: [gpu] - volumes: - - /tmp/.X11-unix:/tmp/.X11-unix - - $XAUTHORITY:/root/.Xauthority environment: - QT_X11_NO_MITSHM: 1 - DISPLAY: $DISPLAY + <<: *common-env NVIDIA_VISIBLE_DEVICES: all NVIDIA_DRIVER_CAPABILITIES: all diff --git a/.github/upstream_jazzy.repos b/.github/upstream_jazzy.repos new file mode 100644 index 0000000000..489db06273 --- /dev/null +++ b/.github/upstream_jazzy.repos @@ -0,0 +1,8 @@ +repositories: + # MoveIt msgs containing POLYLINE planner message definitions (e.g. smoothness_level). + # Required on pre-Kilted ROS distros (e.g. Jazzy) where debian packages lack these definitions. + # TODO: Remove once moveit_msgs with POLYLINE fields is released into targeted distros. + moveit_msgs: + type: git + url: https://github.com/moveit/moveit_msgs.git + version: 2.7.2 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4ad001b68d..11df8df687 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,7 +32,7 @@ jobs: UNDERLAY: ${{ endsWith(matrix.env.IMAGE, '-source') && '/root/ws_moveit/install' || '' }} UPSTREAM_WORKSPACE: .github/upstream.repos BEFORE_SETUP_UPSTREAM_WORKSPACE: git config --global --add safe.directory "*" - AFTER_SETUP_UPSTREAM_WORKSPACE: vcs pull $BASEDIR/upstream_ws/src + AFTER_SETUP_UPSTREAM_WORKSPACE: IMAGE="${{ matrix.env.IMAGE }}"; for f in ".github/upstream_${IMAGE}.repos" ".github/upstream_${IMAGE%-*}.repos"; do test -f "$f" && vcs import $BASEDIR/upstream_ws/src < "$f"; done; vcs pull $BASEDIR/upstream_ws/src TARGET_CMAKE_ARGS: -DCMAKE_BUILD_TYPE=Release CCACHE_DIR: ${{ github.workspace }}/.ccache BASEDIR: ${{ github.workspace }}/.work @@ -51,7 +51,7 @@ jobs: restore-keys: ${{ env.CACHE_PREFIX }} env: GHA_CACHE_SAVE: always - CACHE_PREFIX: upstream_ws-${{ matrix.env.IMAGE }}-${{ hashFiles('.github/upstream.repos') }} + CACHE_PREFIX: upstream_ws-${{ matrix.env.IMAGE }}-${{ hashFiles('.github/upstream.repos', '.github/upstream_*.repos') }} # The target directory cache doesn't include the source directory because # that comes from the checkout. See "prepare target_ws for cache" task below - name: cache target_ws diff --git a/doc/how_to_guides/pilz_industrial_motion_planner/launch/pilz_moveit.launch.xml b/doc/how_to_guides/pilz_industrial_motion_planner/launch/pilz_moveit.launch.xml new file mode 100644 index 0000000000..f3d5298117 --- /dev/null +++ b/doc/how_to_guides/pilz_industrial_motion_planner/launch/pilz_moveit.launch.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/how_to_guides/pilz_industrial_motion_planner/pilz_industrial_motion_planner.rst b/doc/how_to_guides/pilz_industrial_motion_planner/pilz_industrial_motion_planner.rst index 54f4ee6cc3..c367305d96 100644 --- a/doc/how_to_guides/pilz_industrial_motion_planner/pilz_industrial_motion_planner.rst +++ b/doc/how_to_guides/pilz_industrial_motion_planner/pilz_industrial_motion_planner.rst @@ -39,7 +39,7 @@ strictest combination of all limits as a common limit for all joints. Cartesian Limits ---------------- -For Cartesian trajectory generation (LIN/CIRC), the planner needs +For Cartesian trajectory generation (LIN/CIRC/POLYLINE), the planner needs information about the maximum speed in 3D Cartesian space. Namely, translational/rotational velocity/acceleration/deceleration need to be set in the node parameters like this: @@ -60,6 +60,9 @@ rotational trapezoidal shapes. The rotational acceleration is calculated as ``max_trans_acc / max_trans_vel * max_rot_vel`` (and for deceleration accordingly). +You can set different max_trans_vel using ``MotionPlanRequest`` by setting +the field ``max_cartesian_speed`` and the field ``cartesian_speed_limited_link``. + Planning Interface ------------------ @@ -70,7 +73,7 @@ are explained below. For a general introduction on how to fill a ``MotionPlanRequest``, see :ref:`move_group_interface-planning-to-pose-goal`. -You can specify ``"PTP"``, ``"LIN"`` or ``"CIRC"`` as the ``planner_id`` of the ``MotionPlanRequest``. +You can specify ``"PTP"``, ``"LIN"``, ``"CIRC"`` or ``"POLYLINE"`` as the ``planner_id`` of the ``MotionPlanRequest``. The PTP motion command ---------------------- @@ -144,6 +147,7 @@ LIN Input Parameters in ``moveit_msgs::MotionPlanRequest`` translational/rotational velocity - ``max_acceleration_scaling_factor``: scaling factor of maximal Cartesian translational/rotational acceleration/deceleration +- ``max_cartesian_speed``: maximal Cartesian speed for this motion (replaces the max_trans_vel parameter) - ``start_state/joint_state/(name, position and velocity``: joint name/position of the start state. - ``goal_constraints`` (goal can be given in joint space or Cartesian @@ -215,6 +219,7 @@ CIRC Input Parameters in ``moveit_msgs::MotionPlanRequest`` translational/rotational velocity - ``max_acceleration_scaling_factor``: scaling factor of maximal Cartesian translational/rotational acceleration/deceleration +- ``max_cartesian_speed``: maximal Cartesian speed for this motion (replaces the max_trans_vel parameter) - ``start_state/joint_state/(name, position and velocity``: joint name/position of the start state. - ``goal_constraints`` (goal can be given in joint space or Cartesian @@ -260,6 +265,50 @@ CIRC Planning Result in ``moveit_msg::MotionPlanResponse`` - ``group_name``: the name of the planning group - ``error_code/val``: error code of the motion planning +The POLYLINE motion command +--------------------------- + +.. note:: + + The ``POLYLINE`` planner and its parameters (e.g. ``smoothness_level`` in `moveit_msgs/msg/MotionPlanRequest `_) are natively available in binary releases for ROS 2 Kilted and newer. On earlier ROS 2 distributions (such as Jazzy), building ``moveit_msgs`` (version >= 2.7.2) from source is required. + +This planner generates a continuous Cartesian trajectory passing through a sequence of waypoints. +The generated path is a combination of linear segments connected by +circular arcs to smooth the transitions between consecutive lines. A smoothness level scaling factor is used to +determine smoothness by scaling the max possible rounding radius. +The planner automatically filters waypoints that are positioned too closely; +however, the user must ensure the angle between consecutive segments is +sufficiently large to avoid violating minimum rounding constraints. +The planner uses Cartesian limits to generate a trapezoidal +velocity profile in Cartesian space. This planner only accepts a +start state with zero velocity. The planning result is a joint trajectory. The user needs to adapt +the Cartesian velocity/acceleration scaling factor if the motion plan fails due to violation of cartesian limits. +The planner will fail if three or more consecutive waypoints are collinear. + +POLYLINE Input Parameters in ``moveit_msgs::MotionPlanRequest`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- ``planner_id``: ``"POLYLINE"`` +- ``group_name``: the name of the planning group +- ``max_velocity_scaling_factor``: scaling factor of maximal Cartesian + translational/rotational velocity +- ``max_acceleration_scaling_factor``: scaling factor of maximal + Cartesian translational/rotational acceleration/deceleration +- ``max_cartesian_speed``: maximal Cartesian speed for this motion (replaces the max_trans_vel parameter) +- ``start_state/joint_state/(name, position and velocity``: joint + name/position of the start state. +- ``path_constraints``: a list of position constraints to be followed in + Cartesian space. Each waypoint is defined as a ``moveit_msgs::msg::PositionConstraint`` + + - ``path_constraints/position_constraints/constraint_region/primitive_poses/point``: + pose of the point +- ``goal_constraints`` (the last goal point) + - ``goal_constraints/position_constraints/header/frame_id``: + frame this data is associated with + - ``goal_constraints/position_constraints/link_name``: target + link name +- ``smoothness_level``: scaling factor for the maximum possible rounding radius + Examples -------- @@ -424,8 +473,8 @@ is used instead: } The -:codedir:`pilz_sequence.cpp file ` -creates two target poses that will be reached sequentially. +:codedir:`pilz_sequence.cpp file ` +creates a sequence of three commands. :: @@ -435,6 +484,8 @@ creates two target poses that will be reached sequentially. // Set pose blend radius item1.blend_radius = 0.1; + // Set max_cartesian_speed (overwrite the max_trans_vel) + item1.req.max_cartesian_speed = 0.5; // MotionSequenceItem configuration item1.req.group_name = PLANNING_GROUP; @@ -461,7 +512,7 @@ creates two target poses that will be reached sequentially. msg.pose.orientation.w = 0.0; return msg; } (); - item1.req.goal_constraints.push_back(kinematic_constraints::constructGoalConstraints("panda_link8", target_pose_item1)); + item1.req.goal_constraints.push_back(kinematic_constraints::constructGoalConstraints("panda_hand", target_pose_item1)); The service client needs to be initialized: @@ -485,6 +536,7 @@ Then, the request is created: auto service_request = std::make_shared(); service_request->request.items.push_back(item1); service_request->request.items.push_back(item2); + service_request->request.items.push_back(item3); Once the service call is completed, the method ``future.wait_for(timeout_duration)`` blocks until a specified ``timeout_duration`` has elapsed or the result becomes available, whichever comes @@ -546,9 +598,9 @@ The future response is read with the ``future.get()`` method. return 0; } -In this case, the planned trajectory is drawn. Here is a comparison of a blend radius of 0 and 0.1 for the first and second trajectory, respectively. +In this case, the planned trajectory is drawn. Here is a comparison of a blend radius of 0, 0.1, 0.05 for the first, second, and third trajectory (line, line, ellipse), respectively. -.. figure:: trajectory_comparison.jpeg +.. figure:: trajectory_comparison.png :alt: trajectory comparison Action interface @@ -588,6 +640,7 @@ Then, the request is created: moveit_msgs::msg::MotionSequenceRequest sequence_request; sequence_request.items.push_back(item1); sequence_request.items.push_back(item2); + sequence_request.items.push_back(item3); The goal and planning options are configured. A goal response callback and result callback can be included as well. diff --git a/doc/how_to_guides/pilz_industrial_motion_planner/src/pilz_move_group.cpp b/doc/how_to_guides/pilz_industrial_motion_planner/src/pilz_move_group.cpp index 6e58f9abcc..93e1218f00 100644 --- a/doc/how_to_guides/pilz_industrial_motion_planner/src/pilz_move_group.cpp +++ b/doc/how_to_guides/pilz_industrial_motion_planner/src/pilz_move_group.cpp @@ -170,6 +170,74 @@ int main(int argc, char* argv[]) plan_and_execute("[CIRC] Turn"); } + { + auto const goal_pose = [] { + geometry_msgs::msg::PoseStamped msg; + msg.header.frame_id = "world"; + msg.pose.orientation.x = 1.0; + msg.pose.orientation.y = 0.0; + msg.pose.orientation.z = 0.0; + msg.pose.orientation.w = 0.0; + msg.pose.position.x = 0.3; + msg.pose.position.y = 0.0; + msg.pose.position.z = 0.6; + return msg; + }(); + // Move back home using the PTP planner. + move_group_interface.setPlannerId("PTP"); + move_group_interface.setPoseTarget(goal_pose, "panda_hand"); + plan_and_execute("[PTP] Return"); + } + // Clear Marker to show polyline path clearly + moveit_visual_tools.deleteAllMarkers(); + moveit_visual_tools.trigger(); + { + // Move in a heart-shaped Cartesian path using the POLYLINE. + move_group_interface.setPlannerId("POLYLINE"); + // To prevent failure due to exceeding cartesian limits. + move_group_interface.setMaxVelocityScalingFactor(0.05); + move_group_interface.setMaxAccelerationScalingFactor(0.05); + // Define Heart Shape Parametric Equation to collect waypoints + auto heart_eq = [](double t) -> Eigen::Vector3d { + Eigen::Vector3d p; + p.x() = 0.3 + 0.0068 * (17 + 13 * std::cos(t) - 5 * std::cos(2 * t) - 2 * std::cos(3 * t) - cos(4 * t)); + p.y() = 0.0068 * (16.0 * std::pow(std::sin(t), 3)); + p.z() = 0.6; + return p; + }; + + geometry_msgs::msg::PoseStamped msg; + msg.header.frame_id = "world"; + moveit_msgs::msg::Constraints path_constraints; + const int num_points = 40; + for (int i = 0; i <= num_points; ++i) + { + // Get waypoint from parametric equation of heart shape + double t = M_PI + 2.0 * i * M_PI / float(num_points); + Eigen::Vector3d p = heart_eq(t); + msg.pose.position.x = p.x(); + msg.pose.position.y = p.y(); + msg.pose.position.z = p.z(); + msg.pose.orientation.x = 1.0; + msg.pose.orientation.y = 0.0; + msg.pose.orientation.z = 0.0; + msg.pose.orientation.w = 0.0; + // Add waypoint as position constraint + moveit_msgs::msg::PositionConstraint pos_constraint; + pos_constraint.header.frame_id = "world"; + pos_constraint.link_name = "panda_hand"; + pos_constraint.constraint_region.primitive_poses.resize(1); + pos_constraint.constraint_region.primitive_poses[0] = msg.pose; + pos_constraint.weight = 1.0; + path_constraints.position_constraints.push_back(pos_constraint); + } + // Set all position constraints as path constraints + move_group_interface.setPathConstraints(path_constraints); + // Set the last pose as goal + move_group_interface.setPoseTarget(msg, "panda_hand"); + plan_and_execute("[POLYLINE] Heart"); + } + { // Move back home using the PTP planner. move_group_interface.setPlannerId("PTP"); diff --git a/doc/how_to_guides/pilz_industrial_motion_planner/src/pilz_sequence.cpp b/doc/how_to_guides/pilz_industrial_motion_planner/src/pilz_sequence.cpp index 45f210b80d..a458420a79 100644 --- a/doc/how_to_guides/pilz_industrial_motion_planner/src/pilz_sequence.cpp +++ b/doc/how_to_guides/pilz_industrial_motion_planner/src/pilz_sequence.cpp @@ -71,7 +71,9 @@ int main(int argc, char** argv) moveit_msgs::msg::MotionSequenceItem item1; // Set pose blend radius - item1.blend_radius = 0.1; + item1.blend_radius = 0.05; + // Set max_cartesian_speed (overwrite the max_trans_vel) + item1.req.max_cartesian_speed = 0.5; // MotionSequenceItem configuration item1.req.group_name = PLANNING_GROUP; @@ -92,22 +94,22 @@ int main(int argc, char** argv) msg.pose.position.x = 0.3; msg.pose.position.y = -0.2; msg.pose.position.z = 0.6; - msg.pose.orientation.x = 1.0; - msg.pose.orientation.y = 0.0; + msg.pose.orientation.x = 0.924; + msg.pose.orientation.y = -0.380; msg.pose.orientation.z = 0.0; msg.pose.orientation.w = 0.0; return msg; }(); - item1.req.goal_constraints.push_back( - kinematic_constraints::constructGoalConstraints("panda_link8", target_pose_item1)); + item1.req.goal_constraints.push_back(kinematic_constraints::constructGoalConstraints("panda_hand", target_pose_item1)); // ----- Motion Sequence Item 2 // Create a MotionSequenceItem moveit_msgs::msg::MotionSequenceItem item2; // Set pose blend radius - // For the last pose, it must be 0! - item2.blend_radius = 0.0; + item2.blend_radius = 0.05; + // set max_cartesian_speed (overwrite the max_trans_vel) + item2.req.max_cartesian_speed = 1.2; // MotionSequenceItem configuration item2.req.group_name = PLANNING_GROUP; @@ -123,14 +125,71 @@ int main(int argc, char** argv) msg.pose.position.x = 0.3; msg.pose.position.y = -0.2; msg.pose.position.z = 0.8; - msg.pose.orientation.x = 1.0; - msg.pose.orientation.y = 0.0; + msg.pose.orientation.x = 0.924; + msg.pose.orientation.y = -0.380; msg.pose.orientation.z = 0.0; msg.pose.orientation.w = 0.0; return msg; }(); - item2.req.goal_constraints.push_back( - kinematic_constraints::constructGoalConstraints("panda_link8", target_pose_item2)); + item2.req.goal_constraints.push_back(kinematic_constraints::constructGoalConstraints("panda_hand", target_pose_item2)); + + // -------- Motion Sequence Items 3 + moveit_msgs::msg::MotionSequenceItem item3; + + // MotionSequenceItem configuration + item3.req.group_name = PLANNING_GROUP; + item3.req.planner_id = "POLYLINE"; + item3.req.allowed_planning_time = 5.0; + item3.req.max_velocity_scaling_factor = 0.1; + item3.req.max_acceleration_scaling_factor = 0.1; + + // For the last pose, the blending radius must be 0! + item3.blend_radius = 0.0; + // Set max_cartesian_speed (overwrite the max_trans_vel) + item3.req.max_cartesian_speed = 0.1; + + // Extract waypoints along an ellipse + auto ellipse = [](double t) -> Eigen::Vector3d { + Eigen::Vector3d p; + p.x() = 0.3; + p.y() = -0.2 + 0.2 * std::sin(t); + p.z() = 0.6 + 0.2 * std::cos(t); + return p; + }; + + geometry_msgs::msg::PoseStamped msg; + msg.header.frame_id = "world"; + moveit_msgs::msg::Constraints path_constraints; + const int num_points = 20; + for (int i = 0; i <= num_points; ++i) + { + // Get waypoint from parametric equation of ellipse + double t = i * (M_PI / 2.0) / float(num_points); + Eigen::Vector3d p = ellipse(t); + msg.pose.position.x = p.x(); + msg.pose.position.y = p.y(); + msg.pose.position.z = p.z(); + msg.pose.orientation.x = std::cos(M_PI / 8.0 - t / 4.0); + msg.pose.orientation.y = -std::sin(M_PI / 8.0 - t / 4.0); + msg.pose.orientation.z = 0.0; + msg.pose.orientation.w = 0.0; + // Add waypoint as position constraint + moveit_msgs::msg::PositionConstraint pos_constraint; + pos_constraint.header.frame_id = "world"; + pos_constraint.link_name = "panda_hand"; + pos_constraint.constraint_region.primitive_poses.resize(1); + pos_constraint.constraint_region.primitive_poses[0] = msg.pose; + pos_constraint.weight = 1.0; + item3.req.path_constraints.position_constraints.push_back(pos_constraint); + } + // Set smoothness_level if supported by the message definition (e.g. ROS 2 Kilted+ or moveit_msgs >= 2.7.2) + auto set_smoothness_level = [](auto& req, double level) { + if constexpr (requires { req.smoothness_level = level; }) + { + req.smoothness_level = level; + } + }; + set_smoothness_level(item3.req, 0.3); // [ --------------------------------------------------------------- ] // [ ------------------ MoveGroupSequence Service ------------------ ] @@ -151,6 +210,7 @@ int main(int argc, char** argv) auto service_request = std::make_shared(); service_request->request.items.push_back(item1); service_request->request.items.push_back(item2); + service_request->request.items.push_back(item3); // Call the service and process the result auto service_future = service_client->async_send_request(service_request); @@ -223,6 +283,7 @@ int main(int argc, char** argv) moveit_msgs::msg::MotionSequenceRequest sequence_request; sequence_request.items.push_back(item1); sequence_request.items.push_back(item2); + sequence_request.items.push_back(item3); // Create action goal auto goal_msg = MoveGroupSequence::Goal(); diff --git a/doc/how_to_guides/pilz_industrial_motion_planner/trajectory_comparison.jpeg b/doc/how_to_guides/pilz_industrial_motion_planner/trajectory_comparison.jpeg deleted file mode 100644 index b0821bc895..0000000000 Binary files a/doc/how_to_guides/pilz_industrial_motion_planner/trajectory_comparison.jpeg and /dev/null differ diff --git a/doc/how_to_guides/pilz_industrial_motion_planner/trajectory_comparison.png b/doc/how_to_guides/pilz_industrial_motion_planner/trajectory_comparison.png new file mode 100644 index 0000000000..1a4d7545b9 Binary files /dev/null and b/doc/how_to_guides/pilz_industrial_motion_planner/trajectory_comparison.png differ