urkit 0.3.21__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.21 → urkit-0.3.22}/PKG-INFO +1 -1
- {urkit-0.3.21 → urkit-0.3.22}/pyproject.toml +1 -1
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/__init__.py +1 -1
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/geometry.py +60 -63
- {urkit-0.3.21 → 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.21/tests/test_geometry.py +0 -187
- {urkit-0.3.21 → urkit-0.3.22}/README.md +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/setup.cfg +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/__main__.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/cli/__init__.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/cli/colors.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/cli/connection_monitor.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/cli/points.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/cli/teach.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/config.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/connection.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/exceptions.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/gripper/__init__.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/gripper/base.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/gripper/digital.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/gripper/presets.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/gripper/robotiq.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/gripper/robotiq_preamble.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/io.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/motion.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/points.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/robot.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit/telemetry.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit.egg-info/SOURCES.txt +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit.egg-info/dependency_links.txt +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit.egg-info/entry_points.txt +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit.egg-info/requires.txt +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/src/urkit.egg-info/top_level.txt +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/tests/test_exceptions.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/tests/test_gripper.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/tests/test_gripper_factory.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/tests/test_gripper_presets.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/tests/test_move_sequence.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/tests/test_points.py +0 -0
- {urkit-0.3.21 → urkit-0.3.22}/tests/test_robot_integration.py +0 -0
|
@@ -90,13 +90,13 @@ def rpy_to_quat(
|
|
|
90
90
|
def orient_tcp(
|
|
91
91
|
pose: list[float], direction: list[float]
|
|
92
92
|
) -> list[float]:
|
|
93
|
-
"""Orient TCP along a target direction
|
|
93
|
+
"""Orient TCP along a target direction via minimal relative rotation.
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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.
|
|
100
100
|
|
|
101
101
|
Args:
|
|
102
102
|
pose: [x, y, z, rx, ry, rz] rotation vector pose.
|
|
@@ -113,69 +113,66 @@ def orient_tcp(
|
|
|
113
113
|
norm_d = math.sqrt(direction[0] ** 2 + direction[1] ** 2 + direction[2] ** 2)
|
|
114
114
|
if norm_d < 1e-10:
|
|
115
115
|
raise ValueError(f"Direction vector is zero: {direction}")
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
# Current rotation matrix (columns are X, Y, Z axes)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
# Current
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
y_new = [
|
|
146
|
-
z_new[1] * ref[2] - z_new[2] * ref[1],
|
|
147
|
-
z_new[2] * ref[0] - z_new[0] * ref[2],
|
|
148
|
-
z_new[0] * ref[1] - z_new[1] * ref[0],
|
|
149
|
-
]
|
|
150
|
-
norm_y = math.sqrt(y_new[0] ** 2 + y_new[1] ** 2 + y_new[2] ** 2)
|
|
151
|
-
|
|
152
|
-
y_new[0] /= norm_y
|
|
153
|
-
y_new[1] /= norm_y
|
|
154
|
-
y_new[2] /= norm_y
|
|
155
|
-
# x = y cross z
|
|
156
|
-
x_new = [
|
|
157
|
-
y_new[1] * z_new[2] - y_new[2] * z_new[1],
|
|
158
|
-
y_new[2] * z_new[0] - y_new[0] * z_new[2],
|
|
159
|
-
y_new[0] * z_new[1] - y_new[1] * z_new[0],
|
|
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],
|
|
160
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
|
|
161
152
|
else:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
z_new[1] * x_new[2] - z_new[2] * x_new[1],
|
|
168
|
-
z_new[2] * x_new[0] - z_new[0] * x_new[2],
|
|
169
|
-
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],
|
|
170
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)))
|
|
171
162
|
|
|
172
|
-
# Build rotation matrix from
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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],
|
|
177
171
|
]
|
|
178
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
|
+
|
|
179
176
|
rv_target = _matrix_to_rotvec(R_new)
|
|
180
177
|
|
|
181
178
|
return [pos[0], pos[1], pos[2], rv_target[0], rv_target[1], rv_target[2]]
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|