urkit 0.3.20__tar.gz → 0.3.22__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {urkit-0.3.20 → urkit-0.3.22}/PKG-INFO +1 -1
- {urkit-0.3.20 → urkit-0.3.22}/pyproject.toml +1 -1
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/__init__.py +3 -1
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/__main__.py +1 -1
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/cli/teach.py +128 -27
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/geometry.py +86 -48
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit.egg-info/PKG-INFO +1 -1
- urkit-0.3.22/tests/test_geometry.py +286 -0
- urkit-0.3.20/tests/test_geometry.py +0 -187
- {urkit-0.3.20 → urkit-0.3.22}/README.md +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/setup.cfg +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/cli/__init__.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/cli/colors.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/cli/connection_monitor.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/cli/points.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/config.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/connection.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/exceptions.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/gripper/__init__.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/gripper/base.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/gripper/digital.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/gripper/presets.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/gripper/robotiq.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/gripper/robotiq_preamble.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/io.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/motion.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/points.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/robot.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit/telemetry.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit.egg-info/SOURCES.txt +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit.egg-info/dependency_links.txt +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit.egg-info/entry_points.txt +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit.egg-info/requires.txt +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/src/urkit.egg-info/top_level.txt +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/tests/test_exceptions.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/tests/test_gripper.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/tests/test_gripper_factory.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/tests/test_gripper_presets.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/tests/test_move_sequence.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/tests/test_points.py +0 -0
- {urkit-0.3.20 → urkit-0.3.22}/tests/test_robot_integration.py +0 -0
|
@@ -24,7 +24,7 @@ Quick start::
|
|
|
24
24
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
|
|
27
|
-
__version__ = "0.3.
|
|
27
|
+
__version__ = "0.3.22"
|
|
28
28
|
|
|
29
29
|
from urkit.config import load_config, resolve_config
|
|
30
30
|
from urkit.exceptions import (
|
|
@@ -41,6 +41,7 @@ from urkit.exceptions import (
|
|
|
41
41
|
)
|
|
42
42
|
from urkit.geometry import (
|
|
43
43
|
MoveFrame,
|
|
44
|
+
orient_tcp,
|
|
44
45
|
orient_tcp_down,
|
|
45
46
|
quat_to_rotvec,
|
|
46
47
|
quat_to_rpy,
|
|
@@ -80,6 +81,7 @@ __all__ = [
|
|
|
80
81
|
# Move frame
|
|
81
82
|
"MoveFrame",
|
|
82
83
|
# Geometry
|
|
84
|
+
"orient_tcp",
|
|
83
85
|
"orient_tcp_down",
|
|
84
86
|
"quat_to_rotvec",
|
|
85
87
|
"quat_to_rpy",
|
|
@@ -85,7 +85,7 @@ def main() -> None:
|
|
|
85
85
|
"-e", "--expert",
|
|
86
86
|
action="store_true",
|
|
87
87
|
default=False,
|
|
88
|
-
help="Disable safety speed clamping (full speed for goto/tcp-
|
|
88
|
+
help="Disable safety speed clamping (full speed for goto/tcp-orient)",
|
|
89
89
|
)
|
|
90
90
|
teach_parser.add_argument(
|
|
91
91
|
"-v", "--verbose",
|
|
@@ -40,7 +40,7 @@ from urkit.exceptions import (
|
|
|
40
40
|
URKitConnectionError,
|
|
41
41
|
URKitConnectionError as ConnectionError,
|
|
42
42
|
)
|
|
43
|
-
from urkit.geometry import MoveFrame,
|
|
43
|
+
from urkit.geometry import MoveFrame, orient_tcp, transform_pose_delta
|
|
44
44
|
from urkit.gripper.presets import DigitalGripperConfig, PRESETS
|
|
45
45
|
from urkit.motion import FreedriveMode
|
|
46
46
|
from urkit.robot import URRobot
|
|
@@ -357,6 +357,13 @@ def _draw_screen(
|
|
|
357
357
|
except Exception:
|
|
358
358
|
pass
|
|
359
359
|
|
|
360
|
+
# IK reference
|
|
361
|
+
ik_ref = state.get("ik_reference")
|
|
362
|
+
if ik_ref is not None:
|
|
363
|
+
lines.append(f" {blue('IK Ref:'.ljust(lw))} {green(ik_ref if isinstance(ik_ref, str) else 'custom')}")
|
|
364
|
+
else:
|
|
365
|
+
lines.append(f" {blue('IK Ref:'.ljust(lw))} {dim('None')}")
|
|
366
|
+
|
|
360
367
|
if state["freedrive"]:
|
|
361
368
|
mode_label = state["freedrive_mode"].name
|
|
362
369
|
if mode_label == "XYZ":
|
|
@@ -397,7 +404,7 @@ def _draw_screen(
|
|
|
397
404
|
lines.append(f" {yellow('STEP:')} {yellow('1')}: Linear (mm) {yellow('2')}: Angular (°) {yellow('.')}: Reset")
|
|
398
405
|
lines.append(f" {yellow('GRIPPER:')} {yellow('X')}: Open {yellow('C')}: Close {yellow('V')}: Position {yellow('6')}: Speed {yellow('7')}: Force")
|
|
399
406
|
lines.append(f" {yellow('POINTS:')} {yellow('B')}: Save {yellow('G')}: Go To {yellow('H')}: Delete {yellow('R')}: Rename {yellow('P')}: Explorer")
|
|
400
|
-
lines.append(f" {yellow('OTHER:')} {yellow('F')}: Freedrive {yellow('M')}: Frame {yellow('N')}: GoTo Mode {yellow('T')}: TCP
|
|
407
|
+
lines.append(f" {yellow('OTHER:')} {yellow('F')}: Freedrive {yellow('M')}: Frame {yellow('N')}: GoTo Mode {yellow('T')}: TCP Orient")
|
|
401
408
|
lines.append(f" {yellow(' ')} {yellow('0')}: Speed {yellow('Y')}: Save Config")
|
|
402
409
|
lines.append(f" {yellow('EXIT:')} {yellow('ESC')}")
|
|
403
410
|
lines.append(dim("=" * width))
|
|
@@ -445,7 +452,7 @@ def _draw_help() -> None:
|
|
|
445
452
|
lines.append(" OTHER:")
|
|
446
453
|
lines.append(" F → Cycle freedrive: OFF → ALL → XYZ+Rz → OFF")
|
|
447
454
|
lines.append(" M → Toggle move frame: BASE / TOOL")
|
|
448
|
-
lines.append(" T →
|
|
455
|
+
lines.append(" T → Open TCP orient submenu (select axis direction)")
|
|
449
456
|
lines.append(" 0 → Set speed slider (0-100%)")
|
|
450
457
|
lines.append(" Y → Save config (IP, gripper, points path)")
|
|
451
458
|
lines.append("")
|
|
@@ -878,6 +885,110 @@ def _submenu_explore_points(robot: URRobot, messages: list[str]) -> None:
|
|
|
878
885
|
messages.append(f"Error: {e}")
|
|
879
886
|
|
|
880
887
|
|
|
888
|
+
# TCP orientation directions for the submenu
|
|
889
|
+
_TCP_ORIENT_OPTIONS = [
|
|
890
|
+
("−Z (down)", [0.0, 0.0, -1.0]),
|
|
891
|
+
("+Z (up)", [0.0, 0.0, 1.0]),
|
|
892
|
+
("−Y (back)", [0.0, -1.0, 0.0]),
|
|
893
|
+
("+Y (fwd)", [0.0, 1.0, 0.0]),
|
|
894
|
+
("−X (left)", [-1.0, 0.0, 0.0]),
|
|
895
|
+
("+X (right)", [ 1.0, 0.0, 0.0]),
|
|
896
|
+
]
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def _submenu_orient_tcp(
|
|
900
|
+
robot: URRobot, state: dict, messages: list[str], expert_mode: bool = False
|
|
901
|
+
) -> None:
|
|
902
|
+
"""Interactive submenu to orient TCP along a base-axis direction."""
|
|
903
|
+
cursor = 0
|
|
904
|
+
old_settings = termios.tcgetattr(sys.stdin)
|
|
905
|
+
new_settings = termios.tcgetattr(sys.stdin)
|
|
906
|
+
new_settings[3] = new_settings[3] & ~(termios.ICANON | termios.ECHO)
|
|
907
|
+
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, new_settings)
|
|
908
|
+
fd = sys.stdin.fileno()
|
|
909
|
+
|
|
910
|
+
try:
|
|
911
|
+
while True:
|
|
912
|
+
sys.stdout.write("\033[2J\033[1;1H")
|
|
913
|
+
sys.stdout.write(cyan(" === ORIENT TCP ===") + "\n")
|
|
914
|
+
sys.stdout.write(dim(" Arrows navigate · Enter select · ESC cancel") + "\n\n")
|
|
915
|
+
sys.stdout.write(blue(" Point tool Z toward:") + "\n")
|
|
916
|
+
sys.stdout.write(" " + dim("─" * 60) + "\n")
|
|
917
|
+
|
|
918
|
+
for i, (label, _direction) in enumerate(_TCP_ORIENT_OPTIONS):
|
|
919
|
+
marker = green("►") if i == cursor else " "
|
|
920
|
+
sys.stdout.write(f" {marker} {label}\n")
|
|
921
|
+
|
|
922
|
+
sys.stdout.write(f"\n {dim('Enter')} to orient {green(_TCP_ORIENT_OPTIONS[cursor][0])}\n")
|
|
923
|
+
sys.stdout.flush()
|
|
924
|
+
|
|
925
|
+
ready, _, _ = select.select([fd], [], [], 0.1)
|
|
926
|
+
if not ready:
|
|
927
|
+
if _cli_monitor and _cli_monitor.fault_detected:
|
|
928
|
+
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
|
|
929
|
+
raise URKitConnectionError(
|
|
930
|
+
f"Robot fault detected: {_cli_monitor._reason or 'RTDE connection lost'}. "
|
|
931
|
+
"RTDE connection lost."
|
|
932
|
+
)
|
|
933
|
+
continue
|
|
934
|
+
|
|
935
|
+
raw = os.read(fd, 64)
|
|
936
|
+
if not raw:
|
|
937
|
+
continue
|
|
938
|
+
text = raw.decode("ascii", errors="replace")
|
|
939
|
+
i = 0
|
|
940
|
+
selected = None
|
|
941
|
+
while i < len(text):
|
|
942
|
+
ch = text[i]
|
|
943
|
+
if ch == "\x1b":
|
|
944
|
+
if i + 2 < len(text) and text[i + 1] == "[":
|
|
945
|
+
key = text[i + 2]
|
|
946
|
+
if key == "A":
|
|
947
|
+
cursor = max(0, cursor - 1)
|
|
948
|
+
elif key == "B":
|
|
949
|
+
cursor = min(len(_TCP_ORIENT_OPTIONS) - 1, cursor + 1)
|
|
950
|
+
i += 3
|
|
951
|
+
continue
|
|
952
|
+
else:
|
|
953
|
+
return None # ESC → cancel
|
|
954
|
+
if ch == "\n" or ch == "\r":
|
|
955
|
+
selected = cursor
|
|
956
|
+
elif ch == "\x03":
|
|
957
|
+
return None
|
|
958
|
+
i += 1
|
|
959
|
+
|
|
960
|
+
if selected is not None:
|
|
961
|
+
break
|
|
962
|
+
finally:
|
|
963
|
+
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
|
|
964
|
+
|
|
965
|
+
if selected is None:
|
|
966
|
+
messages.append("Cancelled")
|
|
967
|
+
return
|
|
968
|
+
|
|
969
|
+
label, direction = _TCP_ORIENT_OPTIONS[selected]
|
|
970
|
+
try:
|
|
971
|
+
if state["freedrive"]:
|
|
972
|
+
robot.disable_freedrive()
|
|
973
|
+
state["freedrive"] = False
|
|
974
|
+
|
|
975
|
+
pose = robot.get_tcp_pose()
|
|
976
|
+
target = orient_tcp(pose, direction)
|
|
977
|
+
try:
|
|
978
|
+
robot.inverse_kinematics(target)
|
|
979
|
+
except MotionError:
|
|
980
|
+
messages.append(f"Unreachable: TCP {label} — no IK solution")
|
|
981
|
+
return
|
|
982
|
+
|
|
983
|
+
if expert_mode:
|
|
984
|
+
robot.move_to(target, vel=0.5, acc=0.3)
|
|
985
|
+
else:
|
|
986
|
+
robot.move_to(target, vel=0.125, acc=0.1)
|
|
987
|
+
messages.append(f"TCP oriented {label}")
|
|
988
|
+
except MotionError as e:
|
|
989
|
+
messages.append(f"Error: {e}")
|
|
990
|
+
|
|
991
|
+
|
|
881
992
|
# ------------------------------------------------------------------
|
|
882
993
|
# Key input helpers
|
|
883
994
|
# ------------------------------------------------------------------
|
|
@@ -944,7 +1055,7 @@ def _teach_pendant(
|
|
|
944
1055
|
|
|
945
1056
|
Args:
|
|
946
1057
|
robot: Initialized URRobot instance.
|
|
947
|
-
expert_mode: When True, skip safety speed clamping on goto/tcp-
|
|
1058
|
+
expert_mode: When True, skip safety speed clamping on goto/tcp-orient.
|
|
948
1059
|
"""
|
|
949
1060
|
state: dict = {
|
|
950
1061
|
"linear_step": _DEFAULT_LINEAR_STEP,
|
|
@@ -954,6 +1065,7 @@ def _teach_pendant(
|
|
|
954
1065
|
"move_frame": robot.move_frame,
|
|
955
1066
|
"goto_mode": "cartesian", # "cartesian" or "joint" for Go To
|
|
956
1067
|
"speed_slider": 1.0,
|
|
1068
|
+
"ik_reference": robot._ik_reference,
|
|
957
1069
|
}
|
|
958
1070
|
|
|
959
1071
|
# Read the robot's current speed slider so the display matches reality.
|
|
@@ -1193,31 +1305,13 @@ def _teach_pendant(
|
|
|
1193
1305
|
messages.append(f"Go To mode: {state['goto_mode'].capitalize()}")
|
|
1194
1306
|
command_handled = True
|
|
1195
1307
|
|
|
1196
|
-
# --- TCP orient
|
|
1308
|
+
# --- TCP orient submenu ---
|
|
1197
1309
|
elif key == "t":
|
|
1198
1310
|
if state["move_frame"] == MoveFrame.TOOL:
|
|
1199
|
-
messages.append("TCP
|
|
1311
|
+
messages.append("TCP Orient unavailable in TOOL frame")
|
|
1200
1312
|
command_handled = True
|
|
1201
1313
|
else:
|
|
1202
|
-
|
|
1203
|
-
if state["freedrive"]:
|
|
1204
|
-
robot.disable_freedrive()
|
|
1205
|
-
state["freedrive"] = False
|
|
1206
|
-
pose = robot.get_tcp_pose()
|
|
1207
|
-
target = orient_tcp_down(pose)
|
|
1208
|
-
try:
|
|
1209
|
-
robot.inverse_kinematics(target)
|
|
1210
|
-
except MotionError:
|
|
1211
|
-
messages.append("Unreachable: TCP Down — no IK solution")
|
|
1212
|
-
command_handled = True
|
|
1213
|
-
else:
|
|
1214
|
-
if expert_mode:
|
|
1215
|
-
robot.move_to(target, vel=0.5, acc=0.3)
|
|
1216
|
-
else:
|
|
1217
|
-
robot.move_to(target, vel=0.125, acc=0.1)
|
|
1218
|
-
messages.append("TCP oriented downward")
|
|
1219
|
-
except MotionError as e:
|
|
1220
|
-
messages.append(f"Error: {e}")
|
|
1314
|
+
_submenu_orient_tcp(robot, state, messages, expert_mode)
|
|
1221
1315
|
command_handled = True
|
|
1222
1316
|
|
|
1223
1317
|
# --- Gripper ---
|
|
@@ -1460,8 +1554,15 @@ def teach_command(args) -> None:
|
|
|
1460
1554
|
if gripper_name:
|
|
1461
1555
|
print(f" Gripper: {gripper_name}")
|
|
1462
1556
|
print(f" Points: {points_path}")
|
|
1463
|
-
if
|
|
1464
|
-
|
|
1557
|
+
if gripper_config is not None:
|
|
1558
|
+
tcp = gripper_config.tcp_offset
|
|
1559
|
+
non_zero = [v for v in tcp if abs(v) > 1e-6]
|
|
1560
|
+
if len(non_zero) == 1 and abs(tcp[0]) < 1e-6 and abs(tcp[1]) < 1e-6:
|
|
1561
|
+
print(f" TCP: Z={tcp[2]*1000:.1f}mm")
|
|
1562
|
+
elif len(non_zero) <= 3 and all(abs(tcp[i]) < 1e-6 for i in range(3, 6)):
|
|
1563
|
+
print(f" TCP: X={tcp[0]*1000:.1f} Y={tcp[1]*1000:.1f} Z={tcp[2]*1000:.1f}mm")
|
|
1564
|
+
else:
|
|
1565
|
+
print(f" TCP: {list(tcp)}")
|
|
1465
1566
|
|
|
1466
1567
|
# URRobot handles everything: safety recovery, remote mode check,
|
|
1467
1568
|
# power on, brake release, program stop, and RTDE connection.
|
|
@@ -87,75 +87,113 @@ def rpy_to_quat(
|
|
|
87
87
|
)
|
|
88
88
|
|
|
89
89
|
|
|
90
|
-
def
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
preserving the tool's heading (X-axis direction projected to
|
|
95
|
-
the XY plane). This avoids the ambiguity of RPY yaw at roll=π
|
|
96
|
-
and produces a smooth, predictable orientation change.
|
|
90
|
+
def orient_tcp(
|
|
91
|
+
pose: list[float], direction: list[float]
|
|
92
|
+
) -> list[float]:
|
|
93
|
+
"""Orient TCP along a target direction via minimal relative rotation.
|
|
97
94
|
|
|
98
|
-
|
|
95
|
+
Computes the smallest rotation that maps the current tool Z-axis
|
|
96
|
+
onto *direction* (in the base frame), then applies that rotation
|
|
97
|
+
to the current pose. This is a relative rotation from where you
|
|
98
|
+
are — no absolute frame reconstruction, no heading-preservation
|
|
99
|
+
heuristics that cause unexpected large swings.
|
|
99
100
|
|
|
100
101
|
Args:
|
|
101
102
|
pose: [x, y, z, rx, ry, rz] rotation vector pose.
|
|
103
|
+
direction: Target direction for tool Z-axis [dx, dy, dz]
|
|
104
|
+
(normalized automatically).
|
|
102
105
|
|
|
103
106
|
Returns:
|
|
104
|
-
New pose with same position but TCP pointing
|
|
107
|
+
New pose with same position but TCP pointing along direction.
|
|
105
108
|
"""
|
|
106
109
|
pos = pose[:3]
|
|
107
110
|
rv = pose[3:]
|
|
108
111
|
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
#
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
#
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
112
|
+
# Normalize target direction
|
|
113
|
+
norm_d = math.sqrt(direction[0] ** 2 + direction[1] ** 2 + direction[2] ** 2)
|
|
114
|
+
if norm_d < 1e-10:
|
|
115
|
+
raise ValueError(f"Direction vector is zero: {direction}")
|
|
116
|
+
z_target = [d / norm_d for d in direction]
|
|
117
|
+
|
|
118
|
+
# Current rotation matrix (columns are X, Y, Z axes in base frame)
|
|
119
|
+
R_curr = _rotvec_to_matrix(rv)
|
|
120
|
+
|
|
121
|
+
# Current tool Z-axis in base frame (3rd column of R_curr)
|
|
122
|
+
z_curr = [R_curr[i][2] for i in range(3)]
|
|
123
|
+
|
|
124
|
+
# Dot product to determine alignment
|
|
125
|
+
dot_cos = z_curr[0] * z_target[0] + z_curr[1] * z_target[1] + z_curr[2] * z_target[2]
|
|
126
|
+
|
|
127
|
+
# Already aligned within epsilon
|
|
128
|
+
if dot_cos > 1.0 - 1e-10:
|
|
129
|
+
return [pos[0], pos[1], pos[2], rv[0], rv[1], rv[2]]
|
|
130
|
+
|
|
131
|
+
# Compute rotation axis and angle
|
|
132
|
+
if dot_cos < -1.0 + 1e-10:
|
|
133
|
+
# 180° case — z_curr is opposite z_target, cross product is zero
|
|
134
|
+
# Pick a reference vector perpendicular to z_curr
|
|
135
|
+
if abs(z_curr[0]) <= abs(z_curr[1]) and abs(z_curr[0]) <= abs(z_curr[2]):
|
|
136
|
+
ref = [1.0, 0.0, 0.0]
|
|
137
|
+
elif abs(z_curr[1]) <= abs(z_curr[2]):
|
|
138
|
+
ref = [0.0, 1.0, 0.0]
|
|
139
|
+
else:
|
|
140
|
+
ref = [0.0, 0.0, 1.0]
|
|
141
|
+
axis = [
|
|
142
|
+
z_curr[1] * ref[2] - z_curr[2] * ref[1],
|
|
143
|
+
z_curr[2] * ref[0] - z_curr[0] * ref[2],
|
|
144
|
+
z_curr[0] * ref[1] - z_curr[1] * ref[0],
|
|
136
145
|
]
|
|
146
|
+
norm_axis = math.sqrt(axis[0] ** 2 + axis[1] ** 2 + axis[2] ** 2)
|
|
147
|
+
if norm_axis < 1e-10:
|
|
148
|
+
# Should not happen, but guard against it
|
|
149
|
+
axis, norm_axis = [1.0, 0.0, 0.0], 1.0
|
|
150
|
+
ax, ay, az = axis[0] / norm_axis, axis[1] / norm_axis, axis[2] / norm_axis
|
|
151
|
+
angle = math.pi
|
|
137
152
|
else:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
z_new[2] * x_new[0] - z_new[0] * x_new[2],
|
|
144
|
-
z_new[0] * x_new[1] - z_new[1] * x_new[0],
|
|
153
|
+
# General case: rotation axis = z_curr × z_target
|
|
154
|
+
cross = [
|
|
155
|
+
z_curr[1] * z_target[2] - z_curr[2] * z_target[1],
|
|
156
|
+
z_curr[2] * z_target[0] - z_curr[0] * z_target[2],
|
|
157
|
+
z_curr[0] * z_target[1] - z_curr[1] * z_target[0],
|
|
145
158
|
]
|
|
159
|
+
norm_cross = math.sqrt(cross[0] ** 2 + cross[1] ** 2 + cross[2] ** 2)
|
|
160
|
+
ax, ay, az = cross[0] / norm_cross, cross[1] / norm_cross, cross[2] / norm_cross
|
|
161
|
+
angle = math.acos(max(-1.0, min(1.0, dot_cos)))
|
|
146
162
|
|
|
147
|
-
# Build rotation matrix from
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
163
|
+
# Build delta rotation matrix from axis-angle (Rodrigues)
|
|
164
|
+
s = math.sin(angle)
|
|
165
|
+
c = math.cos(angle)
|
|
166
|
+
oc = 1.0 - c
|
|
167
|
+
R_delta = [
|
|
168
|
+
[c + ax*ax*oc, ax*ay*oc - az*s, ax*az*oc + ay*s],
|
|
169
|
+
[ay*ax*oc + az*s, c + ay*ay*oc, ay*az*oc - ax*s],
|
|
170
|
+
[az*ax*oc - ay*s, az*ay*oc + ax*s, c + az*az*oc],
|
|
152
171
|
]
|
|
153
172
|
|
|
173
|
+
# Apply delta: R_new = R_delta @ R_curr (rotate current orientation by delta)
|
|
174
|
+
R_new = _mat_mul(R_delta, R_curr)
|
|
175
|
+
|
|
154
176
|
rv_target = _matrix_to_rotvec(R_new)
|
|
155
177
|
|
|
156
178
|
return [pos[0], pos[1], pos[2], rv_target[0], rv_target[1], rv_target[2]]
|
|
157
179
|
|
|
158
180
|
|
|
181
|
+
def orient_tcp_down(pose: list[float]) -> list[float]:
|
|
182
|
+
"""Orient TCP downward while preserving tool heading.
|
|
183
|
+
|
|
184
|
+
Points the tool Z-axis straight down (base frame -Z) while
|
|
185
|
+
preserving the tool's heading. Convenience wrapper around
|
|
186
|
+
:func:`orient_tcp`.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
pose: [x, y, z, rx, ry, rz] rotation vector pose.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
New pose with same position but TCP pointing downward.
|
|
193
|
+
"""
|
|
194
|
+
return orient_tcp(pose, [0.0, 0.0, -1.0])
|
|
195
|
+
|
|
196
|
+
|
|
159
197
|
# ------------------------------------------------------------------
|
|
160
198
|
# Quaternion algebra helpers
|
|
161
199
|
# ------------------------------------------------------------------
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Tests for quaternion/rotation vector geometry helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from urkit.geometry import (
|
|
10
|
+
orient_tcp,
|
|
11
|
+
orient_tcp_down,
|
|
12
|
+
quat_to_rotvec,
|
|
13
|
+
quat_to_rpy,
|
|
14
|
+
rpy_to_quat,
|
|
15
|
+
rotvec_to_quat,
|
|
16
|
+
_rotvec_to_matrix,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestRotvecToQuat:
|
|
21
|
+
"""Rotation vector to quaternion conversion."""
|
|
22
|
+
|
|
23
|
+
def test_identity(self):
|
|
24
|
+
q = rotvec_to_quat([0, 0, 0])
|
|
25
|
+
assert abs(q[3] - 1.0) < 1e-10 # w = 1
|
|
26
|
+
assert abs(q[0]) < 1e-10
|
|
27
|
+
assert abs(q[1]) < 1e-10
|
|
28
|
+
assert abs(q[2]) < 1e-10
|
|
29
|
+
|
|
30
|
+
def test_180_degrees_x_axis(self):
|
|
31
|
+
"""180° rotation around X axis."""
|
|
32
|
+
q = rotvec_to_quat([math.pi, 0, 0])
|
|
33
|
+
assert abs(q[0] - 1.0) < 1e-10 # x = 1
|
|
34
|
+
assert abs(q[1]) < 1e-10
|
|
35
|
+
assert abs(q[2]) < 1e-10
|
|
36
|
+
assert abs(q[3]) < 1e-10 # w = 0
|
|
37
|
+
|
|
38
|
+
def test_90_degrees_z_axis(self):
|
|
39
|
+
"""90° rotation around Z axis."""
|
|
40
|
+
q = rotvec_to_quat([0, 0, math.pi / 2])
|
|
41
|
+
assert abs(q[0]) < 1e-10
|
|
42
|
+
assert abs(q[1]) < 1e-10
|
|
43
|
+
assert abs(q[2] - math.sin(math.pi / 4)) < 1e-10
|
|
44
|
+
assert abs(q[3] - math.cos(math.pi / 4)) < 1e-10
|
|
45
|
+
|
|
46
|
+
def test_roundtrip(self):
|
|
47
|
+
"""rotvec -> quat -> rotvec should be identity."""
|
|
48
|
+
rv = [0.5, 0.3, 0.1]
|
|
49
|
+
q = rotvec_to_quat(rv)
|
|
50
|
+
back = quat_to_rotvec(q)
|
|
51
|
+
for a, b in zip(rv, back):
|
|
52
|
+
assert abs(a - b) < 1e-10
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestQuatToRotvec:
|
|
56
|
+
"""Quaternion to rotation vector conversion."""
|
|
57
|
+
|
|
58
|
+
def test_identity_quat(self):
|
|
59
|
+
rv = quat_to_rotvec((0, 0, 0, 1))
|
|
60
|
+
assert all(abs(v) < 1e-10 for v in rv)
|
|
61
|
+
|
|
62
|
+
def test_roundtrip(self):
|
|
63
|
+
"""quat -> rotvec -> quat should be identity."""
|
|
64
|
+
q = (0.7071, 0, 0, 0.7071) # 90° around X
|
|
65
|
+
rv = quat_to_rotvec(q)
|
|
66
|
+
back = rotvec_to_quat(rv)
|
|
67
|
+
for a, b in zip(q, back):
|
|
68
|
+
assert abs(a - b) < 1e-5
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestQuatToRpy:
|
|
72
|
+
"""Quaternion to RPY extraction."""
|
|
73
|
+
|
|
74
|
+
def test_identity(self):
|
|
75
|
+
roll, pitch, yaw = quat_to_rpy((0, 0, 0, 1))
|
|
76
|
+
assert abs(roll) < 1e-10
|
|
77
|
+
assert abs(pitch) < 1e-10
|
|
78
|
+
assert abs(yaw) < 1e-10
|
|
79
|
+
|
|
80
|
+
def test_90_deg_roll(self):
|
|
81
|
+
"""90° roll should give roll=pi/2."""
|
|
82
|
+
q = rotvec_to_quat([math.pi / 2, 0, 0])
|
|
83
|
+
roll, pitch, yaw = quat_to_rpy(q)
|
|
84
|
+
assert abs(roll - math.pi / 2) < 1e-10
|
|
85
|
+
assert abs(pitch) < 1e-10
|
|
86
|
+
assert abs(yaw) < 1e-10
|
|
87
|
+
|
|
88
|
+
def test_roundtrip(self):
|
|
89
|
+
"""rpy -> quat -> rpy should be identity."""
|
|
90
|
+
r, p, y = 0.5, 0.3, 0.1
|
|
91
|
+
q = rpy_to_quat(r, p, y)
|
|
92
|
+
back_r, back_p, back_y = quat_to_rpy(q)
|
|
93
|
+
assert abs(r - back_r) < 1e-10
|
|
94
|
+
assert abs(p - back_p) < 1e-10
|
|
95
|
+
assert abs(y - back_y) < 1e-10
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestRpyToQuat:
|
|
99
|
+
"""RPY to quaternion conversion."""
|
|
100
|
+
|
|
101
|
+
def test_zero_rpy(self):
|
|
102
|
+
q = rpy_to_quat(0, 0, 0)
|
|
103
|
+
assert abs(q[0]) < 1e-10
|
|
104
|
+
assert abs(q[1]) < 1e-10
|
|
105
|
+
assert abs(q[2]) < 1e-10
|
|
106
|
+
assert abs(q[3] - 1.0) < 1e-10
|
|
107
|
+
|
|
108
|
+
def test_180_roll(self):
|
|
109
|
+
"""180° roll -> known quaternion."""
|
|
110
|
+
q = rpy_to_quat(math.pi, 0, 0)
|
|
111
|
+
assert abs(q[0] - 1.0) < 1e-10
|
|
112
|
+
assert abs(q[1]) < 1e-10
|
|
113
|
+
assert abs(q[2]) < 1e-10
|
|
114
|
+
assert abs(q[3]) < 1e-10
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TestOrientTcpDown:
|
|
118
|
+
"""TCP downward orientation."""
|
|
119
|
+
|
|
120
|
+
def test_preserves_position(self):
|
|
121
|
+
pose = [0.5, 0.3, 0.2, 0, 0, 0]
|
|
122
|
+
result = orient_tcp_down(pose)
|
|
123
|
+
assert result[0] == 0.5
|
|
124
|
+
assert result[1] == 0.3
|
|
125
|
+
assert result[2] == 0.2
|
|
126
|
+
|
|
127
|
+
def test_z_axis_points_down(self):
|
|
128
|
+
"""Resulting tool Z-axis should point straight down in base frame."""
|
|
129
|
+
pose = [0.5, 0.3, 0.2, 0, 0, 0]
|
|
130
|
+
result = orient_tcp_down(pose)
|
|
131
|
+
rv = result[3:]
|
|
132
|
+
# For roll=π, Z-axis = (0, 0, -1)
|
|
133
|
+
angle = math.sqrt(sum(v**2 for v in rv))
|
|
134
|
+
if angle > 1e-10:
|
|
135
|
+
az = rv[2] / angle
|
|
136
|
+
c = math.cos(angle)
|
|
137
|
+
oc = 1.0 - c
|
|
138
|
+
# R[2][2] = c + az*az*oc — tool Z in base frame
|
|
139
|
+
z_z = c + az * az * oc
|
|
140
|
+
assert abs(z_z - (-1.0)) < 1e-10
|
|
141
|
+
|
|
142
|
+
def test_minimal_rotation_angle(self):
|
|
143
|
+
"""Orient from yaw=0.5 to down should produce minimal rotation."""
|
|
144
|
+
q = rpy_to_quat(0, 0, 0.5)
|
|
145
|
+
rv = quat_to_rotvec(q)
|
|
146
|
+
pose = [0.5, 0.3, 0.2, rv[0], rv[1], rv[2]]
|
|
147
|
+
result = orient_tcp_down(pose)
|
|
148
|
+
|
|
149
|
+
# The minimal rotation from Z=[0,0,1] to Z=[0,0,-1] is 180°
|
|
150
|
+
result_angle = math.sqrt(sum(v**2 for v in result[3:]))
|
|
151
|
+
assert abs(result_angle - math.pi) < 1e-10
|
|
152
|
+
|
|
153
|
+
def test_handles_gimbal_lock(self):
|
|
154
|
+
"""Pitch=90° (gimbal lock) should still produce valid down orientation."""
|
|
155
|
+
q = rpy_to_quat(0, math.pi / 2, 0.5)
|
|
156
|
+
rv = quat_to_rotvec(q)
|
|
157
|
+
pose = [0.5, 0.3, 0.2, rv[0], rv[1], rv[2]]
|
|
158
|
+
result = orient_tcp_down(pose)
|
|
159
|
+
# Should not crash, Z should point down
|
|
160
|
+
assert len(result) == 6
|
|
161
|
+
assert result[:3] == pose[:3] # position preserved
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TestFullRoundtrip:
|
|
165
|
+
"""Full chain: rotvec -> quat -> rpy -> quat -> rotvec."""
|
|
166
|
+
|
|
167
|
+
def test_various_angles(self):
|
|
168
|
+
test_vectors = [
|
|
169
|
+
[0, 0, 0],
|
|
170
|
+
[math.pi / 6, 0, 0],
|
|
171
|
+
[0, math.pi / 4, 0],
|
|
172
|
+
[0, 0, math.pi / 3],
|
|
173
|
+
[0.5, 0.3, 0.2],
|
|
174
|
+
[1.0, 0.5, 0.3],
|
|
175
|
+
]
|
|
176
|
+
for rv in test_vectors:
|
|
177
|
+
q = rotvec_to_quat(rv)
|
|
178
|
+
r, p, y = quat_to_rpy(q)
|
|
179
|
+
q2 = rpy_to_quat(r, p, y)
|
|
180
|
+
rv2 = quat_to_rotvec(q2)
|
|
181
|
+
for a, b in zip(rv, rv2):
|
|
182
|
+
assert abs(a - b) < 1e-10, f"Failed for {rv}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class TestOrientTcp:
|
|
186
|
+
"""TCP orientation in arbitrary directions (minimal relative rotation)."""
|
|
187
|
+
|
|
188
|
+
def test_preserves_position(self):
|
|
189
|
+
pose = [0.5, 0.3, 0.2, 0, 0, 0]
|
|
190
|
+
result = orient_tcp(pose, [1, 0, 0])
|
|
191
|
+
assert result[0] == 0.5
|
|
192
|
+
assert result[1] == 0.3
|
|
193
|
+
assert result[2] == 0.2
|
|
194
|
+
|
|
195
|
+
def test_z_points_along_target(self):
|
|
196
|
+
"""Tool Z-axis should point along the target direction."""
|
|
197
|
+
pose = [0.5, 0.3, 0.2, 0, 0, 0]
|
|
198
|
+
result = orient_tcp(pose, [1, 0, 0])
|
|
199
|
+
R = _rotvec_to_matrix(result[3:])
|
|
200
|
+
z = [R[i][2] for i in range(3)]
|
|
201
|
+
assert abs(z[0] - 1.0) < 1e-10
|
|
202
|
+
assert abs(z[1]) < 1e-10
|
|
203
|
+
assert abs(z[2]) < 1e-10
|
|
204
|
+
|
|
205
|
+
def test_z_points_along_neg_y(self):
|
|
206
|
+
pose = [0.5, 0.3, 0.2, 0, 0, 0]
|
|
207
|
+
result = orient_tcp(pose, [0, -1, 0])
|
|
208
|
+
R = _rotvec_to_matrix(result[3:])
|
|
209
|
+
z = [R[i][2] for i in range(3)]
|
|
210
|
+
assert abs(z[0]) < 1e-10
|
|
211
|
+
assert abs(z[1] - (-1.0)) < 1e-10
|
|
212
|
+
assert abs(z[2]) < 1e-10
|
|
213
|
+
|
|
214
|
+
def test_minimal_rotation_non_180(self):
|
|
215
|
+
"""Z up (yaw=30°) → +X: delta should be 90°, not 120°."""
|
|
216
|
+
q = rpy_to_quat(0, 0, math.radians(30))
|
|
217
|
+
rv = quat_to_rotvec(q)
|
|
218
|
+
pose = [0.5, 0.3, 0.2, rv[0], rv[1], rv[2]]
|
|
219
|
+
result = orient_tcp(pose, [1, 0, 0])
|
|
220
|
+
# Compute delta rotation: R_delta = R_new @ R_curr^T
|
|
221
|
+
R_curr = _rotvec_to_matrix(pose[3:])
|
|
222
|
+
R_new = _rotvec_to_matrix(result[3:])
|
|
223
|
+
# Transpose of R_curr (since it's orthogonal, inverse = transpose)
|
|
224
|
+
R_curr_T = [[R_curr[0][0], R_curr[1][0], R_curr[2][0]],
|
|
225
|
+
[R_curr[0][1], R_curr[1][1], R_curr[2][1]],
|
|
226
|
+
[R_curr[0][2], R_curr[1][2], R_curr[2][2]]]
|
|
227
|
+
R_delta = [
|
|
228
|
+
[sum(R_new[i][k] * R_curr_T[k][j] for k in range(3)) for j in range(3)]
|
|
229
|
+
for i in range(3)
|
|
230
|
+
]
|
|
231
|
+
trace = R_delta[0][0] + R_delta[1][1] + R_delta[2][2]
|
|
232
|
+
delta_angle = math.acos(max(-1, min(1, (trace - 1) / 2)))
|
|
233
|
+
# Minimal rotation from [0,0,1] to [1,0,0] is 90°
|
|
234
|
+
assert abs(delta_angle - math.pi / 2) < 1e-10, f"delta_angle={delta_angle}"
|
|
235
|
+
|
|
236
|
+
def test_already_aligned_returns_unchanged(self):
|
|
237
|
+
"""If Z already points along target, pose should be unchanged."""
|
|
238
|
+
pose = [0.5, 0.3, 0.2, 0, 0, 0]
|
|
239
|
+
result = orient_tcp(pose, [0, 0, 1])
|
|
240
|
+
assert result[3:] == pose[3:]
|
|
241
|
+
|
|
242
|
+
def test_180_degree_flip(self):
|
|
243
|
+
"""Z up → Z down should give a valid 180° rotation."""
|
|
244
|
+
pose = [0.5, 0.3, 0.2, 0, 0, 0]
|
|
245
|
+
result = orient_tcp(pose, [0, 0, -1])
|
|
246
|
+
angle = math.sqrt(sum(v**2 for v in result[3:]))
|
|
247
|
+
assert abs(angle - math.pi) < 1e-10
|
|
248
|
+
R = _rotvec_to_matrix(result[3:])
|
|
249
|
+
z = [R[i][2] for i in range(3)]
|
|
250
|
+
assert abs(z[0]) < 1e-10
|
|
251
|
+
assert abs(z[1]) < 1e-10
|
|
252
|
+
assert abs(z[2] - (-1.0)) < 1e-10
|
|
253
|
+
|
|
254
|
+
def test_raises_on_zero_direction(self):
|
|
255
|
+
pose = [0.5, 0.3, 0.2, 0, 0, 0]
|
|
256
|
+
with pytest.raises(ValueError):
|
|
257
|
+
orient_tcp(pose, [0, 0, 0])
|
|
258
|
+
|
|
259
|
+
def test_various_starting_poses(self):
|
|
260
|
+
"""Multiple starting orientations should all produce valid results."""
|
|
261
|
+
targets = [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]]
|
|
262
|
+
for roll in [0, math.radians(45), math.radians(90)]:
|
|
263
|
+
for pitch in [0, math.radians(-30), math.radians(60)]:
|
|
264
|
+
for yaw in [0, math.radians(30)]:
|
|
265
|
+
q = rpy_to_quat(roll, pitch, yaw)
|
|
266
|
+
rv = quat_to_rotvec(q)
|
|
267
|
+
pose = [0.5, 0.3, 0.2, rv[0], rv[1], rv[2]]
|
|
268
|
+
for target in targets:
|
|
269
|
+
result = orient_tcp(pose, target)
|
|
270
|
+
# Verify Z points along target
|
|
271
|
+
R = _rotvec_to_matrix(result[3:])
|
|
272
|
+
z = [R[i][2] for i in range(3)]
|
|
273
|
+
norm_t = math.sqrt(target[0]**2 + target[1]**2 + target[2]**2)
|
|
274
|
+
t = [target[i]/norm_t for i in range(3)]
|
|
275
|
+
dot = z[0]*t[0] + z[1]*t[1] + z[2]*t[2]
|
|
276
|
+
assert abs(dot - 1.0) < 1e-10, (
|
|
277
|
+
f"Z not aligned: roll={roll}, pitch={pitch}, "
|
|
278
|
+
f"yaw={yaw}, target={target}"
|
|
279
|
+
)
|
|
280
|
+
# Verify rotation matrix is proper (det = 1)
|
|
281
|
+
det = (
|
|
282
|
+
R[0][0]*(R[1][1]*R[2][2] - R[1][2]*R[2][1])
|
|
283
|
+
- R[0][1]*(R[1][0]*R[2][2] - R[1][2]*R[2][0])
|
|
284
|
+
+ R[0][2]*(R[1][0]*R[2][1] - R[1][1]*R[2][0])
|
|
285
|
+
)
|
|
286
|
+
assert abs(det - 1.0) < 1e-10
|
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
"""Tests for quaternion/rotation vector geometry helpers."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import math
|
|
6
|
-
|
|
7
|
-
from urkit.geometry import (
|
|
8
|
-
orient_tcp_down,
|
|
9
|
-
quat_to_rotvec,
|
|
10
|
-
quat_to_rpy,
|
|
11
|
-
rpy_to_quat,
|
|
12
|
-
rotvec_to_quat,
|
|
13
|
-
)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class TestRotvecToQuat:
|
|
17
|
-
"""Rotation vector to quaternion conversion."""
|
|
18
|
-
|
|
19
|
-
def test_identity(self):
|
|
20
|
-
q = rotvec_to_quat([0, 0, 0])
|
|
21
|
-
assert abs(q[3] - 1.0) < 1e-10 # w = 1
|
|
22
|
-
assert abs(q[0]) < 1e-10
|
|
23
|
-
assert abs(q[1]) < 1e-10
|
|
24
|
-
assert abs(q[2]) < 1e-10
|
|
25
|
-
|
|
26
|
-
def test_180_degrees_x_axis(self):
|
|
27
|
-
"""180° rotation around X axis."""
|
|
28
|
-
q = rotvec_to_quat([math.pi, 0, 0])
|
|
29
|
-
assert abs(q[0] - 1.0) < 1e-10 # x = 1
|
|
30
|
-
assert abs(q[1]) < 1e-10
|
|
31
|
-
assert abs(q[2]) < 1e-10
|
|
32
|
-
assert abs(q[3]) < 1e-10 # w = 0
|
|
33
|
-
|
|
34
|
-
def test_90_degrees_z_axis(self):
|
|
35
|
-
"""90° rotation around Z axis."""
|
|
36
|
-
q = rotvec_to_quat([0, 0, math.pi / 2])
|
|
37
|
-
assert abs(q[0]) < 1e-10
|
|
38
|
-
assert abs(q[1]) < 1e-10
|
|
39
|
-
assert abs(q[2] - math.sin(math.pi / 4)) < 1e-10
|
|
40
|
-
assert abs(q[3] - math.cos(math.pi / 4)) < 1e-10
|
|
41
|
-
|
|
42
|
-
def test_roundtrip(self):
|
|
43
|
-
"""rotvec -> quat -> rotvec should be identity."""
|
|
44
|
-
rv = [0.5, 0.3, 0.1]
|
|
45
|
-
q = rotvec_to_quat(rv)
|
|
46
|
-
back = quat_to_rotvec(q)
|
|
47
|
-
for a, b in zip(rv, back):
|
|
48
|
-
assert abs(a - b) < 1e-10
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
class TestQuatToRotvec:
|
|
52
|
-
"""Quaternion to rotation vector conversion."""
|
|
53
|
-
|
|
54
|
-
def test_identity_quat(self):
|
|
55
|
-
rv = quat_to_rotvec((0, 0, 0, 1))
|
|
56
|
-
assert all(abs(v) < 1e-10 for v in rv)
|
|
57
|
-
|
|
58
|
-
def test_roundtrip(self):
|
|
59
|
-
"""quat -> rotvec -> quat should be identity."""
|
|
60
|
-
q = (0.7071, 0, 0, 0.7071) # 90° around X
|
|
61
|
-
rv = quat_to_rotvec(q)
|
|
62
|
-
back = rotvec_to_quat(rv)
|
|
63
|
-
for a, b in zip(q, back):
|
|
64
|
-
assert abs(a - b) < 1e-5
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
class TestQuatToRpy:
|
|
68
|
-
"""Quaternion to RPY extraction."""
|
|
69
|
-
|
|
70
|
-
def test_identity(self):
|
|
71
|
-
roll, pitch, yaw = quat_to_rpy((0, 0, 0, 1))
|
|
72
|
-
assert abs(roll) < 1e-10
|
|
73
|
-
assert abs(pitch) < 1e-10
|
|
74
|
-
assert abs(yaw) < 1e-10
|
|
75
|
-
|
|
76
|
-
def test_90_deg_roll(self):
|
|
77
|
-
"""90° roll should give roll=pi/2."""
|
|
78
|
-
q = rotvec_to_quat([math.pi / 2, 0, 0])
|
|
79
|
-
roll, pitch, yaw = quat_to_rpy(q)
|
|
80
|
-
assert abs(roll - math.pi / 2) < 1e-10
|
|
81
|
-
assert abs(pitch) < 1e-10
|
|
82
|
-
assert abs(yaw) < 1e-10
|
|
83
|
-
|
|
84
|
-
def test_roundtrip(self):
|
|
85
|
-
"""rpy -> quat -> rpy should be identity."""
|
|
86
|
-
r, p, y = 0.5, 0.3, 0.1
|
|
87
|
-
q = rpy_to_quat(r, p, y)
|
|
88
|
-
back_r, back_p, back_y = quat_to_rpy(q)
|
|
89
|
-
assert abs(r - back_r) < 1e-10
|
|
90
|
-
assert abs(p - back_p) < 1e-10
|
|
91
|
-
assert abs(y - back_y) < 1e-10
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
class TestRpyToQuat:
|
|
95
|
-
"""RPY to quaternion conversion."""
|
|
96
|
-
|
|
97
|
-
def test_zero_rpy(self):
|
|
98
|
-
q = rpy_to_quat(0, 0, 0)
|
|
99
|
-
assert abs(q[0]) < 1e-10
|
|
100
|
-
assert abs(q[1]) < 1e-10
|
|
101
|
-
assert abs(q[2]) < 1e-10
|
|
102
|
-
assert abs(q[3] - 1.0) < 1e-10
|
|
103
|
-
|
|
104
|
-
def test_180_roll(self):
|
|
105
|
-
"""180° roll -> known quaternion."""
|
|
106
|
-
q = rpy_to_quat(math.pi, 0, 0)
|
|
107
|
-
assert abs(q[0] - 1.0) < 1e-10
|
|
108
|
-
assert abs(q[1]) < 1e-10
|
|
109
|
-
assert abs(q[2]) < 1e-10
|
|
110
|
-
assert abs(q[3]) < 1e-10
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
class TestOrientTcpDown:
|
|
114
|
-
"""TCP downward orientation."""
|
|
115
|
-
|
|
116
|
-
def test_preserves_position(self):
|
|
117
|
-
pose = [0.5, 0.3, 0.2, 0, 0, 0]
|
|
118
|
-
result = orient_tcp_down(pose)
|
|
119
|
-
assert result[0] == 0.5
|
|
120
|
-
assert result[1] == 0.3
|
|
121
|
-
assert result[2] == 0.2
|
|
122
|
-
|
|
123
|
-
def test_z_axis_points_down(self):
|
|
124
|
-
"""Resulting tool Z-axis should point straight down in base frame."""
|
|
125
|
-
pose = [0.5, 0.3, 0.2, 0, 0, 0]
|
|
126
|
-
result = orient_tcp_down(pose)
|
|
127
|
-
rv = result[3:]
|
|
128
|
-
# For roll=π, Z-axis = (0, 0, -1)
|
|
129
|
-
angle = math.sqrt(sum(v**2 for v in rv))
|
|
130
|
-
if angle > 1e-10:
|
|
131
|
-
ax, ay, az = rv[0]/angle, rv[1]/angle, rv[2]/angle
|
|
132
|
-
s, c = math.sin(angle), math.cos(angle)
|
|
133
|
-
oc = 1 - c
|
|
134
|
-
# R[2][2] = c + az*az*oc — tool Z in base frame
|
|
135
|
-
z_z = c + az*az*oc
|
|
136
|
-
assert abs(z_z - (-1.0)) < 1e-10
|
|
137
|
-
|
|
138
|
-
def test_preserves_heading(self):
|
|
139
|
-
"""Tool X-axis direction in XY plane should be preserved."""
|
|
140
|
-
# Start with yaw=0.5 (tool facing ~45° from X)
|
|
141
|
-
q = rpy_to_quat(0, 0, 0.5)
|
|
142
|
-
rv = quat_to_rotvec(q)
|
|
143
|
-
pose = [0.5, 0.3, 0.2, rv[0], rv[1], rv[2]]
|
|
144
|
-
result = orient_tcp_down(pose)
|
|
145
|
-
|
|
146
|
-
# Original X axis angle in XY plane
|
|
147
|
-
orig_q = rotvec_to_quat(pose[3:])
|
|
148
|
-
orig_rpy = quat_to_rpy(orig_q)
|
|
149
|
-
orig_heading = orig_rpy[2] # yaw = heading in XY plane
|
|
150
|
-
|
|
151
|
-
# Result X axis angle in XY plane
|
|
152
|
-
result_q = rotvec_to_quat(result[3:])
|
|
153
|
-
result_rpy = quat_to_rpy(result_q)
|
|
154
|
-
result_heading = result_rpy[2]
|
|
155
|
-
|
|
156
|
-
assert abs(orig_heading - result_heading) < 1e-10
|
|
157
|
-
|
|
158
|
-
def test_handles_gimbal_lock(self):
|
|
159
|
-
"""Pitch=90° (gimbal lock) should still produce valid down orientation."""
|
|
160
|
-
q = rpy_to_quat(0, math.pi / 2, 0.5)
|
|
161
|
-
rv = quat_to_rotvec(q)
|
|
162
|
-
pose = [0.5, 0.3, 0.2, rv[0], rv[1], rv[2]]
|
|
163
|
-
result = orient_tcp_down(pose)
|
|
164
|
-
# Should not crash, Z should point down
|
|
165
|
-
assert len(result) == 6
|
|
166
|
-
assert result[:3] == pose[:3] # position preserved
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
class TestFullRoundtrip:
|
|
170
|
-
"""Full chain: rotvec -> quat -> rpy -> quat -> rotvec."""
|
|
171
|
-
|
|
172
|
-
def test_various_angles(self):
|
|
173
|
-
test_vectors = [
|
|
174
|
-
[0, 0, 0],
|
|
175
|
-
[math.pi / 6, 0, 0],
|
|
176
|
-
[0, math.pi / 4, 0],
|
|
177
|
-
[0, 0, math.pi / 3],
|
|
178
|
-
[0.5, 0.3, 0.2],
|
|
179
|
-
[1.0, 0.5, 0.3],
|
|
180
|
-
]
|
|
181
|
-
for rv in test_vectors:
|
|
182
|
-
q = rotvec_to_quat(rv)
|
|
183
|
-
r, p, y = quat_to_rpy(q)
|
|
184
|
-
q2 = rpy_to_quat(r, p, y)
|
|
185
|
-
rv2 = quat_to_rotvec(q2)
|
|
186
|
-
for a, b in zip(rv, rv2):
|
|
187
|
-
assert abs(a - b) < 1e-10, f"Failed for {rv}"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|