yostlabs 2025.1.16__py3-none-any.whl
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.
- yostlabs/__init__.py +0 -0
- yostlabs/communication/__init__.py +0 -0
- yostlabs/communication/base.py +90 -0
- yostlabs/communication/serial.py +141 -0
- yostlabs/math/__init__.py +1 -0
- yostlabs/math/quaternion.py +149 -0
- yostlabs/math/vector.py +31 -0
- yostlabs/tss3/__init__.py +0 -0
- yostlabs/tss3/api.py +1712 -0
- yostlabs/tss3/consts.py +24 -0
- yostlabs/tss3/eepts.py +228 -0
- yostlabs/tss3/utils/__init__.py +0 -0
- yostlabs/tss3/utils/calibration.py +153 -0
- yostlabs/tss3/utils/parser.py +256 -0
- yostlabs/tss3/utils/streaming.py +435 -0
- yostlabs/tss3/utils/version.py +76 -0
- yostlabs-2025.1.16.dist-info/METADATA +50 -0
- yostlabs-2025.1.16.dist-info/RECORD +20 -0
- yostlabs-2025.1.16.dist-info/WHEEL +4 -0
- yostlabs-2025.1.16.dist-info/licenses/LICENSE +21 -0
yostlabs/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from typing import Generator
|
|
2
|
+
|
|
3
|
+
class ThreespaceInputStream:
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Reads specified number of bytes.
|
|
7
|
+
If that many bytes are not available after timeout, less data will be returned
|
|
8
|
+
"""
|
|
9
|
+
def read(self, num_bytes: int) -> bytes:
|
|
10
|
+
raise NotImplementedError()
|
|
11
|
+
|
|
12
|
+
def read_all(self):
|
|
13
|
+
return self.read(self.length)
|
|
14
|
+
|
|
15
|
+
def read_until(self, expected: bytes) -> bytes:
|
|
16
|
+
raise NotImplementedError()
|
|
17
|
+
|
|
18
|
+
"""Allows reading without removing the data from the buffer"""
|
|
19
|
+
def peek(self, num_bytes: int) -> bytes:
|
|
20
|
+
raise NotImplementedError()
|
|
21
|
+
|
|
22
|
+
def peek_until(self, expected: bytes, max_length: int = None) -> bytes:
|
|
23
|
+
raise NotImplementedError()
|
|
24
|
+
|
|
25
|
+
def readline(self) -> bytes:
|
|
26
|
+
return self.read_until(b"\n")
|
|
27
|
+
|
|
28
|
+
def peekline(self, max_length: int = None) -> bytes:
|
|
29
|
+
return self.peek_until(b"\n", max_length=max_length)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def length(self) -> int:
|
|
33
|
+
raise NotImplementedError()
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def timeout(self) -> float:
|
|
37
|
+
raise NotImplementedError()
|
|
38
|
+
|
|
39
|
+
@timeout.setter
|
|
40
|
+
def timeout(self, timeout: float):
|
|
41
|
+
raise NotImplementedError()
|
|
42
|
+
|
|
43
|
+
class ThreespaceOutputStream:
|
|
44
|
+
|
|
45
|
+
"""Write the given bytes"""
|
|
46
|
+
def write(self, bytes):
|
|
47
|
+
raise NotImplementedError()
|
|
48
|
+
|
|
49
|
+
class ThreespaceComClass(ThreespaceInputStream, ThreespaceOutputStream):
|
|
50
|
+
"""
|
|
51
|
+
Base class for a com class to use with the sensor object.
|
|
52
|
+
Com classes should be initialized without connection and require
|
|
53
|
+
there open called before use
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def close(self):
|
|
57
|
+
raise NotImplementedError()
|
|
58
|
+
|
|
59
|
+
def open(self) -> bool:
|
|
60
|
+
"""
|
|
61
|
+
Should return True on success, False on failure
|
|
62
|
+
If already open, should stay open
|
|
63
|
+
"""
|
|
64
|
+
raise NotImplementedError()
|
|
65
|
+
|
|
66
|
+
def check_open(self) -> bool:
|
|
67
|
+
"""
|
|
68
|
+
Should return True if the port is currently open, False otherwise.
|
|
69
|
+
Must give the current state, not a cached state
|
|
70
|
+
"""
|
|
71
|
+
raise NotImplementedError()
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def auto_detect() -> Generator["ThreespaceComClass", None, None]:
|
|
75
|
+
"""
|
|
76
|
+
Returns a list of com classes of the same type called on nearby
|
|
77
|
+
"""
|
|
78
|
+
raise NotImplementedError()
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def reenumerates(self) -> bool:
|
|
82
|
+
"""
|
|
83
|
+
If the device Re-Enumerates when going from bootloader to firmware or vice versa, this must return True.
|
|
84
|
+
This indicates to the API that it must search for the new com class representing the object when switching between bootloader and firmware
|
|
85
|
+
"""
|
|
86
|
+
raise NotImplementedError
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def name(self) -> str:
|
|
90
|
+
raise NotImplementedError()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from yostlabs.communication.base import *
|
|
2
|
+
import serial
|
|
3
|
+
import serial.tools.list_ports
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ThreespaceSerialComClass(ThreespaceComClass):
|
|
7
|
+
|
|
8
|
+
PID_V3_MASK = 0x3000
|
|
9
|
+
PID_BOOTLOADER = 0x1000
|
|
10
|
+
|
|
11
|
+
VID = 0x2476
|
|
12
|
+
|
|
13
|
+
DEFAULT_BAUDRATE = 115200
|
|
14
|
+
DEFAULT_TIMEOUT = 2
|
|
15
|
+
|
|
16
|
+
def __init__(self, ser: serial.Serial | str):
|
|
17
|
+
if isinstance(ser, serial.Serial):
|
|
18
|
+
self.ser = ser
|
|
19
|
+
elif isinstance(ser, str):
|
|
20
|
+
self.ser = serial.Serial(ser, baudrate=ThreespaceSerialComClass.DEFAULT_BAUDRATE, timeout=ThreespaceSerialComClass.DEFAULT_TIMEOUT)
|
|
21
|
+
else:
|
|
22
|
+
raise TypeError("Invalid type for creating a ThreespaceSerialComClass:", type(ser), ser)
|
|
23
|
+
|
|
24
|
+
self.ser = ser
|
|
25
|
+
|
|
26
|
+
self.peek_buffer = bytearray()
|
|
27
|
+
self.peek_length = 0
|
|
28
|
+
|
|
29
|
+
def write(self, bytes: bytes):
|
|
30
|
+
self.ser.write(bytes)
|
|
31
|
+
|
|
32
|
+
def read(self, num_bytes: int):
|
|
33
|
+
if self.peek_length >= num_bytes:
|
|
34
|
+
result = self.peek_buffer[:num_bytes]
|
|
35
|
+
self.peek_length -= num_bytes
|
|
36
|
+
del self.peek_buffer[:num_bytes]
|
|
37
|
+
else:
|
|
38
|
+
result = self.peek_buffer + self.ser.read(num_bytes - self.peek_length) #Must supply the amount desired to read instead of just buffering so the timeout works
|
|
39
|
+
self.peek_buffer.clear()
|
|
40
|
+
self.peek_length = 0
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
def peek(self, num_bytes: int):
|
|
44
|
+
if self.peek_length >= num_bytes:
|
|
45
|
+
return self.peek_buffer[:num_bytes]
|
|
46
|
+
else:
|
|
47
|
+
self.peek_buffer += self.ser.read(num_bytes - self.peek_length) #Must supply the amount desired to read instead of just buffering so the timeout works
|
|
48
|
+
self.peek_length = len(self.peek_buffer) #The read may have timed out, so calculate new size
|
|
49
|
+
return self.peek_buffer.copy()
|
|
50
|
+
|
|
51
|
+
def read_until(self, expected: bytes) -> bytes:
|
|
52
|
+
if expected in self.peek_buffer:
|
|
53
|
+
length = self.peek_buffer.index(expected) + len(expected)
|
|
54
|
+
result = self.peek_buffer[:length]
|
|
55
|
+
self.peek_length -= length
|
|
56
|
+
del self.peek_buffer[:length]
|
|
57
|
+
return result
|
|
58
|
+
#Have to actually read from serial port until the data is available
|
|
59
|
+
result = self.peek_buffer + self.ser.read_until(expected)
|
|
60
|
+
self.peek_buffer.clear()
|
|
61
|
+
self.peek_length = 0
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
def peek_until(self, expected: bytes, max_length: int = None) -> bytes:
|
|
65
|
+
if expected in self.peek_buffer:
|
|
66
|
+
length = self.peek_buffer.index(expected) + len(expected)
|
|
67
|
+
if max_length is not None and length > max_length:
|
|
68
|
+
length = max_length
|
|
69
|
+
result = self.peek_buffer[:length]
|
|
70
|
+
return result
|
|
71
|
+
if max_length is not None and self.peek_length >= max_length:
|
|
72
|
+
return self.peek_buffer[:max_length]
|
|
73
|
+
|
|
74
|
+
#Have to actually read from serial port until the data is available
|
|
75
|
+
if max_length is not None:
|
|
76
|
+
max_length = max(0, max_length - self.peek_length)
|
|
77
|
+
self.peek_buffer += self.ser.read_until(expected, size=max_length)
|
|
78
|
+
self.peek_length = len(self.peek_buffer)
|
|
79
|
+
return self.peek_buffer.copy()
|
|
80
|
+
|
|
81
|
+
def close(self):
|
|
82
|
+
self.ser.close()
|
|
83
|
+
self.peek_buffer.clear()
|
|
84
|
+
self.peek_length = 0
|
|
85
|
+
|
|
86
|
+
def open(self):
|
|
87
|
+
try:
|
|
88
|
+
self.ser.open()
|
|
89
|
+
except:
|
|
90
|
+
return False
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
def check_open(self):
|
|
94
|
+
try:
|
|
95
|
+
self.ser.in_waiting
|
|
96
|
+
except:
|
|
97
|
+
return False
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def length(self):
|
|
102
|
+
return self.peek_length + self.ser.in_waiting
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def timeout(self) -> float:
|
|
106
|
+
return self.ser.timeout
|
|
107
|
+
|
|
108
|
+
@timeout.setter
|
|
109
|
+
def timeout(self, timeout: float):
|
|
110
|
+
self.ser.timeout = timeout
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def reenumerates(self) -> bool:
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def name(self) -> str:
|
|
118
|
+
return self.ser.port
|
|
119
|
+
|
|
120
|
+
#This is not part of the ThreespaceComClass interface, but is useful as a utility for those directly using the ThreespaceSerialComClass
|
|
121
|
+
@staticmethod
|
|
122
|
+
def enumerate_ports():
|
|
123
|
+
cls = ThreespaceSerialComClass
|
|
124
|
+
ports = serial.tools.list_ports.comports()
|
|
125
|
+
for port in ports:
|
|
126
|
+
if port.vid == cls.VID and (port.pid & cls.PID_V3_MASK == cls.PID_V3_MASK or port.pid == cls.PID_BOOTLOADER):
|
|
127
|
+
yield port
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def auto_detect(default_timeout: float = 2, default_baudrate: int = 115200) -> Generator["ThreespaceSerialComClass", None, None]:
|
|
131
|
+
"""
|
|
132
|
+
Returns a list of com classes of the same type called on nearby.
|
|
133
|
+
These ports will start unopened. This allows the caller to get a list of ports without having to connect.
|
|
134
|
+
"""
|
|
135
|
+
cls = ThreespaceSerialComClass
|
|
136
|
+
ports = serial.tools.list_ports.comports()
|
|
137
|
+
for port in ports:
|
|
138
|
+
if port.vid == cls.VID and (port.pid & cls.PID_V3_MASK == cls.PID_V3_MASK or port.pid == cls.PID_BOOTLOADER):
|
|
139
|
+
ser = serial.Serial(None, baudrate=default_baudrate, timeout=default_timeout) #By setting port as None, can create an object without immediately opening the port
|
|
140
|
+
ser.port = port.device #Now assign the port, allowing the serial object to exist without being opened yet
|
|
141
|
+
yield ThreespaceSerialComClass(ser)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__ = ["quaternion", "vector"]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import yostlabs.math.vector as _vec
|
|
3
|
+
|
|
4
|
+
def quat_mul(a: list[float], b: list[float]):
|
|
5
|
+
out = [0, 0, 0, 0]
|
|
6
|
+
x = 0; y = 1; z = 2; w = 3
|
|
7
|
+
out[w] = a[w]*b[w] - a[x]*b[x] - a[y]*b[y] - a[z]*b[z]
|
|
8
|
+
out[x] = a[w]*b[x] + a[x]*b[w] + a[y]*b[z] - a[z]*b[y]
|
|
9
|
+
out[y] = a[w]*b[y] + a[y]*b[w] + a[z]*b[x] - a[x]*b[z]
|
|
10
|
+
out[z] = a[w]*b[z] + a[z]*b[w] + a[x]*b[y] - a[y]*b[x]
|
|
11
|
+
return out
|
|
12
|
+
|
|
13
|
+
#Rotates quaternion b by quaternion a, it does not combine them
|
|
14
|
+
def quat_rotate(a: list[float], b: list[float]):
|
|
15
|
+
inv = a.copy()
|
|
16
|
+
inv[0] *= -1
|
|
17
|
+
inv[1] *= -1
|
|
18
|
+
inv[2] *= -1
|
|
19
|
+
axis = [b[0], b[1], b[2], 0]
|
|
20
|
+
halfway = quat_mul(a, axis)
|
|
21
|
+
final = quat_mul(halfway, inv)
|
|
22
|
+
return [*final[:3], b[3]]
|
|
23
|
+
|
|
24
|
+
def quat_inverse(quat: list[float]):
|
|
25
|
+
return [-quat[0], -quat[1], -quat[2], quat[3]]
|
|
26
|
+
|
|
27
|
+
def quat_rotate_vec(quat: list[float], vec: list[float]):
|
|
28
|
+
inv = quat_inverse(quat)
|
|
29
|
+
tmp = [vec[0], vec[1], vec[2], 0]
|
|
30
|
+
halfway = quat_mul(quat, tmp)
|
|
31
|
+
final = quat_mul(halfway, inv)
|
|
32
|
+
return [final[0], final[1], final[2]]
|
|
33
|
+
|
|
34
|
+
def angles_to_quaternion(angles: list[float], order: str, degrees=True):
|
|
35
|
+
quat = [0, 0, 0, 1]
|
|
36
|
+
for i in range(len(angles)):
|
|
37
|
+
axis = order[i]
|
|
38
|
+
angle = angles[i]
|
|
39
|
+
if degrees:
|
|
40
|
+
angle = math.radians(angle)
|
|
41
|
+
unit_vec = _vec.axis_to_unit_vector(axis)
|
|
42
|
+
w = math.cos(angle / 2)
|
|
43
|
+
imaginary = math.sin(angle / 2)
|
|
44
|
+
unit_vec = [v * imaginary for v in unit_vec]
|
|
45
|
+
angle_quat = [*unit_vec, w]
|
|
46
|
+
#print(f"{axis} {angle} {angle_quat}")
|
|
47
|
+
quat = quat_mul(quat, angle_quat)
|
|
48
|
+
return quat
|
|
49
|
+
|
|
50
|
+
def quat_from_axis_angle(axis: list[float], angle: float):
|
|
51
|
+
imaginary = math.sin(angle / 2)
|
|
52
|
+
quat = [imaginary * v for v in axis]
|
|
53
|
+
quat.append(math.cos(angle / 2))
|
|
54
|
+
return quat
|
|
55
|
+
|
|
56
|
+
def quat_from_two_vectors(forward: list[float], down: list[float]):
|
|
57
|
+
"""
|
|
58
|
+
This function requires two orthogonal vectors to work
|
|
59
|
+
"""
|
|
60
|
+
forward_reference = [0, 0, 1]
|
|
61
|
+
down_reference = [0, -1, 0]
|
|
62
|
+
|
|
63
|
+
forward = _vec.vec_normalize(forward)
|
|
64
|
+
down = _vec.vec_normalize(down)
|
|
65
|
+
|
|
66
|
+
#Create the first rotation to align the forward axis
|
|
67
|
+
axis_of_rotation = _vec.vec_cross(forward_reference, forward)
|
|
68
|
+
axis_of_rotation = _vec.vec_normalize(axis_of_rotation)
|
|
69
|
+
if not any(abs(v) > 0 for v in axis_of_rotation):
|
|
70
|
+
axis_of_rotation = down_reference #This is just a direct 180 degree rotation around any orthogonal axis, so just use the down_ref
|
|
71
|
+
dot = min(1, max(-1, _vec.vec_dot(forward_reference, forward)))
|
|
72
|
+
angle1 = math.acos(dot)
|
|
73
|
+
imaginary = math.sin(angle1/2)
|
|
74
|
+
quat = [v * imaginary for v in axis_of_rotation] #XYZ
|
|
75
|
+
quat.append(math.cos(angle1/2)) #W
|
|
76
|
+
|
|
77
|
+
#Update the reference to figure out where it is after the turn
|
|
78
|
+
down_reference = quat_rotate_vec(quat, down_reference)
|
|
79
|
+
|
|
80
|
+
#find the rotation to make the remaining reference align with its given value
|
|
81
|
+
axis_of_rotation = _vec.vec_cross(down_reference, down)
|
|
82
|
+
axis_of_rotation = _vec.vec_normalize(axis_of_rotation)
|
|
83
|
+
if not any(abs(v) > 0 for v in axis_of_rotation):
|
|
84
|
+
axis_of_rotation = forward #Rotate along the final forward vector until up is aligned
|
|
85
|
+
dot = min(1, max(-1, _vec.vec_dot(down_reference, down)))
|
|
86
|
+
angle2 = math.acos(dot)
|
|
87
|
+
imaginary = math.sin(angle2/2)
|
|
88
|
+
rotation = [v * imaginary for v in axis_of_rotation] #XYZ
|
|
89
|
+
rotation.append(math.cos(angle2/2)) #W
|
|
90
|
+
|
|
91
|
+
quat = quat_mul(rotation, quat)
|
|
92
|
+
return quat
|
|
93
|
+
|
|
94
|
+
def quaternion_to_3x3_rotation_matrix(quat):
|
|
95
|
+
"""
|
|
96
|
+
Convert a quaternion in form x, y, z, w to a rotation matrix
|
|
97
|
+
"""
|
|
98
|
+
x, y, z, w = quat
|
|
99
|
+
|
|
100
|
+
fTx = 2.0 * x
|
|
101
|
+
fTy = 2.0 * y
|
|
102
|
+
fTz = 2.0 * z
|
|
103
|
+
fTwx = fTx * w
|
|
104
|
+
fTwy = fTy * w
|
|
105
|
+
fTwz = fTz * w
|
|
106
|
+
fTxx = fTx * x
|
|
107
|
+
fTxy = fTy * x
|
|
108
|
+
fTxz = fTz * x
|
|
109
|
+
fTyy = fTy * y
|
|
110
|
+
fTyz = fTz * y
|
|
111
|
+
fTzz = fTz * z
|
|
112
|
+
|
|
113
|
+
out = [0] * 9
|
|
114
|
+
out[0] = 1.0-(fTyy+fTzz)
|
|
115
|
+
out[1] = fTxy-fTwz
|
|
116
|
+
out[2] = fTxz+fTwy
|
|
117
|
+
out[3] = fTxy+fTwz
|
|
118
|
+
out[4] = 1.0-(fTxx+fTzz)
|
|
119
|
+
out[5] = fTyz-fTwx
|
|
120
|
+
out[6] = fTxz-fTwy
|
|
121
|
+
out[7] = fTyz + fTwx
|
|
122
|
+
out[8] = 1.0 - (fTxx+fTyy)
|
|
123
|
+
|
|
124
|
+
return [
|
|
125
|
+
[out[0], out[1], out[2]],
|
|
126
|
+
[out[3], out[4], out[5]],
|
|
127
|
+
[out[6], out[7], out[8]],
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
def quaternion_global_to_local(quat, vec):
|
|
131
|
+
inverse = quat_inverse(quat)
|
|
132
|
+
return quat_rotate_vec(inverse, vec)
|
|
133
|
+
|
|
134
|
+
def quaternion_local_to_global(quat, vec):
|
|
135
|
+
return quat_rotate_vec(quat, vec)
|
|
136
|
+
|
|
137
|
+
#https://splines.readthedocs.io/en/latest/rotation/slerp.html
|
|
138
|
+
def slerp(a, b, t):
|
|
139
|
+
dot = _vec.vec_dot(a, b)
|
|
140
|
+
if dot < 0: #To force it to be the shortest route
|
|
141
|
+
b = [-v for v in b]
|
|
142
|
+
|
|
143
|
+
theta = math.acos(dot)
|
|
144
|
+
sin_theta = math.sin(theta)
|
|
145
|
+
r1 = math.sin(1 - t) * theta / sin_theta
|
|
146
|
+
r2 = math.sin(t * theta) / sin_theta
|
|
147
|
+
a = [r1 * v for v in a]
|
|
148
|
+
b = [r2 * v for v in b]
|
|
149
|
+
return _vec.vec_normalize([v + w for v, w in zip(a, b)])
|
yostlabs/math/vector.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
def vec_len(vector: list[float|int]):
|
|
2
|
+
return sum(v ** 2 for v in vector) ** 0.5
|
|
3
|
+
|
|
4
|
+
def vec_dot(a: list[float], b: list[float]):
|
|
5
|
+
return sum(a[i] * b[i] for i in range(len(a)))
|
|
6
|
+
|
|
7
|
+
def vec_cross(a: list[float], b: list[float]):
|
|
8
|
+
cross = [0, 0, 0]
|
|
9
|
+
cross[0] = a[1] * b[2] - a[2] * b[1]
|
|
10
|
+
cross[1] = a[2] * b[0] - a[0] * b[2]
|
|
11
|
+
cross[2] = a[0] * b[1] - a[1] * b[0]
|
|
12
|
+
return cross
|
|
13
|
+
|
|
14
|
+
def vec_normalize(vec: list[float]):
|
|
15
|
+
l = vec_len(vec)
|
|
16
|
+
if l == 0:
|
|
17
|
+
return vec
|
|
18
|
+
return [v / l for v in vec]
|
|
19
|
+
|
|
20
|
+
def vec_is_right_handed(order: str, negations: list[bool]):
|
|
21
|
+
right_handed = order.lower() in ("xzy", "yxz", "zyx")
|
|
22
|
+
for i in range(3):
|
|
23
|
+
if negations[i]:
|
|
24
|
+
right_handed = not right_handed
|
|
25
|
+
return right_handed
|
|
26
|
+
|
|
27
|
+
def axis_to_unit_vector(axis: str):
|
|
28
|
+
axis = axis.lower()
|
|
29
|
+
if axis == 'x' or axis == 0: return [1, 0, 0]
|
|
30
|
+
if axis == 'y' or axis == 1: return [0, 1, 0]
|
|
31
|
+
if axis == 'z' or axis == 2: return [0, 0, 1]
|
|
File without changes
|