feat: add release-to-move TCP gizmo control in Webots

Add a native Webots marker for setting the active tool's TCP position and
orientation. Dragging edits the preview; releasing the mouse submits one
complete, checked joint trajectory with configured TCP speed limits.
Unreachable targets reset the marker to the current TCP.

Preserve coordinate, joint, and control-file commands through motion
reservations and a controller action proxy. Give the gizmo lowest priority
and finish its committed trajectory before dispatching queued regular
commands, including waits longer than the former two-second service timeout.

Place the patron TCP at its working end, 55.2 mm along local Z, and use the
flange TCP when no tool is attached. Add digital_twin.tcp_gizmo configuration,
ROS interfaces, launch integration, and regression coverage.

Document operation, configuration, tool attachment, speed limits, and
troubleshooting in both Russian and English.

Validation: 42 Python tests and 8 C++ tests passed. Webots regressions covered
one action per release, translation and rotation with and without a tool,
unreachable targets, and ordinary commands waiting for gizmo completion.
This commit is contained in:
Даниил Грабарь
2026-09-17 17:11:53 +03:00
parent 9f0f05df87
commit 3a5bc28bba
40 changed files with 2373 additions and 70 deletions
@@ -0,0 +1,112 @@
"""Manual Webots speed regression: work -> 10 cm inward TCP translation.
Run against iiwa.launch.py simulate:=true. Checks actual feedback, both peak
TCP speed and convergence, and prints elapsed simulation/wall time.
"""
import copy
import math
import time
import xml.etree.ElementTree as ET
import rclpy
from rclpy.action import ActionClient
from rclpy.parameter_client import AsyncParameterClient
from control_msgs.action import FollowJointTrajectory
from controller_manager_msgs.srv import ListControllers
from trajectory_msgs.msg import JointTrajectoryPoint
from iiwa_msgs.msg import GizmoCommand, GizmoState
def main():
rclpy.init()
node = rclpy.create_node('gizmo_webots_speed')
states = []
node.create_subscription(GizmoState, '/cobot/tcp_gizmo/state', states.append, 10)
publisher = node.create_publisher(GizmoCommand, '/cobot/tcp_gizmo/command', 1)
def wait(predicate, timeout=30.):
deadline = time.monotonic() + timeout
while not predicate():
assert time.monotonic() < deadline, 'Timed out waiting for simulation'
rclpy.spin_once(node, timeout_sec=.01)
def xyz(state):
p = state.actual.pose.position
return p.x, p.y, p.z
def stamp(state):
t = state.actual.header.stamp
return t.sec + t.nanosec * 1e-9
try:
params = AsyncParameterClient(node, '/tcp_gizmo')
assert params.wait_for_services(timeout_sec=30.)
future = params.get_parameters([
'simulation', 'use_sim_time', 'max_linear_speed', 'robot_description_semantic'])
wait(future.done)
values = future.result().values
assert values[0].bool_value and values[1].bool_value, 'Webots simulation only'
speed_limit = values[2].double_value
work = ET.fromstring(values[3].string_value).find("group_state[@name='work']")
assert work is not None
controllers = node.create_client(ListControllers, '/controller_manager/list_controllers')
assert controllers.wait_for_service(timeout_sec=30.)
deadline = time.monotonic() + 30.
while True:
listing = controllers.call_async(ListControllers.Request())
wait(listing.done)
if any(c.name == 'iiwa_arm_controller' and c.state == 'active'
for c in listing.result().controller):
break
assert time.monotonic() < deadline, 'Controller did not activate'
rclpy.spin_once(node, timeout_sec=.1)
wait(lambda: states and states[-1].enabled)
action = ActionClient(node, FollowJointTrajectory, '/iiwa_arm_controller/follow_joint_trajectory')
assert action.wait_for_server(timeout_sec=5.)
goal = FollowJointTrajectory.Goal()
goal.trajectory.joint_names = [joint.attrib['name'] for joint in work]
point = JointTrajectoryPoint(positions=[float(joint.attrib['value']) for joint in work])
point.time_from_start.sec = 3
goal.trajectory.points = [point]
future = action.send_goal_async(goal)
wait(future.done)
assert future.result().accepted
done = future.result().get_result_async()
wait(done.done)
assert done.result().result.error_code == 0
wait(lambda: states[-1].enabled)
initial = states[-1]
target = copy.deepcopy(initial.actual)
target.pose.position.x -= .1
start = len(states) - 1
epoch = initial.epoch
wall_start = time.monotonic()
publisher.publish(GizmoCommand(target=target, epoch=epoch))
while states[-1].epoch == epoch:
assert time.monotonic() - wall_start < 30., 'TCP did not reach 10 cm target'
end = time.monotonic() + .025
while time.monotonic() < end:
rclpy.spin_once(node, timeout_sec=.005)
assert states[-1].reason == 'target reached', states[-1].reason
wait(lambda: initial.actual.pose.position.x - states[-1].actual.pose.position.x >= .099, 2.)
samples = states[start:]
speeds = [math.dist(xyz(a), xyz(b)) / (stamp(b) - stamp(a))
for a, b in zip(samples, samples[1:]) if stamp(b) > stamp(a)]
elapsed = stamp(samples[-1]) - stamp(initial)
peak = max(speeds)
distance = math.dist(xyz(initial), xyz(samples[-1]))
print(f'limit={speed_limit:.3f} m/s; distance={distance:.6f} m; '
f'simulation_time={elapsed:.3f} s; wall_time={time.monotonic() - wall_start:.3f} s; '
f'mean={distance / elapsed:.4f} m/s; peak={peak:.4f} m/s', flush=True)
assert peak <= speed_limit * 1.05 + .002, 'Measured TCP speed exceeds configured cap'
# At 0.50 m/s, a 10 cm work-pose move must not consist of hundreds of
# tiny acceleration-limited 64 ms steps. Keep margin for simulator load.
if speed_limit >= .5:
assert elapsed < 3., f'Artificial short-step speed bottleneck: {elapsed:.3f} s'
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()