yostlabs 2025.1.3.4__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.
@@ -0,0 +1,24 @@
1
+ THREESPACE_HEADER_STATUS_BIT_POS = 0
2
+ THREESPACE_HEADER_TIMESTAMP_BIT_POS = 1
3
+ THREESPACE_HEADER_ECHO_BIT_POS = 2
4
+ THREESPACE_HEADER_CHECKSUM_BIT_POS = 3
5
+ THREESPACE_HEADER_SERIAL_BIT_POS = 4
6
+ THREESPACE_HEADER_LENGTH_BIT_POS = 5
7
+ THREESPACE_HEADER_NUM_BITS = 6
8
+
9
+ THREESPACE_HEADER_STATUS_BIT = (1 << THREESPACE_HEADER_STATUS_BIT_POS)
10
+ THREESPACE_HEADER_TIMESTAMP_BIT = (1 << THREESPACE_HEADER_TIMESTAMP_BIT_POS)
11
+ THREESPACE_HEADER_ECHO_BIT = (1 << THREESPACE_HEADER_ECHO_BIT_POS)
12
+ THREESPACE_HEADER_CHECKSUM_BIT = (1 << THREESPACE_HEADER_CHECKSUM_BIT_POS)
13
+ THREESPACE_HEADER_SERIAL_BIT = (1 << THREESPACE_HEADER_SERIAL_BIT_POS)
14
+ THREESPACE_HEADER_LENGTH_BIT = (1 << THREESPACE_HEADER_LENGTH_BIT_POS)
15
+
16
+ FIRMWARE_VALID_BIT = 0x01
17
+
18
+ PASSIVE_CALIBRATE_GYRO = (1 << 0)
19
+ PASSIVE_CALIBRATE_MAG_REF = (1 << 1)
20
+
21
+ STREAMING_MAX_HZ = 2000
22
+
23
+ THREESPACE_OUTPUT_MODE_ASCII = 1
24
+ THREESPACE_OUTPUT_MODE_BINARY = 2
yostlabs/tss3/eepts.py ADDED
@@ -0,0 +1,228 @@
1
+ from dataclasses import dataclass, field, fields
2
+ from typing import ClassVar
3
+
4
+ YL_SENSOR_BIT_GYRO: int = 1
5
+ YL_SENSOR_BIT_ACCEL: int = 2
6
+ YL_SENSOR_BIT_MAG: int = 4
7
+ YL_SENSOR_BIT_BARO: int = 8
8
+ YL_SENSOR_BIT_TEMP: int = 16
9
+ YL_SENSOR_BIT_GPS: int = 32
10
+
11
+ @dataclass
12
+ class YL_EEPTS_INPUT_DATA:
13
+ gyro_data: list[float] = None # XYZ in radians/sec. only needs to be float32
14
+ accel_data: list[float] = None # XYZ in G force units(9.8 m/s^2). only needs to be float32
15
+ mag_data: list[float] = None # XYZ in Gauss. only needs to be float32
16
+ orient_data: list[float] = None # XYZW Optional, may not be here if YL_BUILTIN_ORIENT is false
17
+ barometer_data: float = 0 # in hPa. only needs to be float32
18
+ temperature: float = 0 # in degrees C. only needs to be float32
19
+ gps_longitude: float = 0 # in decimal degrees (positive east, negative west). needs to be float64
20
+ gps_latitude: float = 0 # in decimal degrees (positive north, negative south). needs to be float64
21
+ gps_altitude: float = 0 # in meters. only needs to be float32
22
+ timestamp: int = 0 # in microseconds. uint32_t
23
+
24
+ #Locomotion Modes
25
+ YL_LOCOMOTION_IDLE: int = 0
26
+ YL_LOCOMOTION_WALKING: int = 1
27
+ YL_LOCOMOTION_JOGGING: int = 2
28
+ YL_LOCOMOTION_RUNNING: int = 3
29
+ YL_LOCOMOTION_CRAWLING: int = 4
30
+ YL_LOCOMOTION_OTHER: int = 6
31
+ YL_LOCOMOTION_UNKNOWN: int = 5
32
+
33
+ #Sensor Locations
34
+ YL_SENSOR_RSHOULDER: int = 5
35
+ YL_SENSOR_BACK: int = 3
36
+ YL_SENSOR_RHAND: int = 4
37
+ YL_SENSOR_WAIST: int = 2
38
+ YL_SENSOR_CHEST: int = 1
39
+ YL_SENSOR_UNKNOWN: int = 0
40
+
41
+ LocomotionModes = {
42
+ YL_LOCOMOTION_IDLE: "Idle",
43
+ YL_LOCOMOTION_WALKING: "Walking",
44
+ YL_LOCOMOTION_JOGGING: "Jogging",
45
+ YL_LOCOMOTION_RUNNING: "Running",
46
+ YL_LOCOMOTION_CRAWLING: "Crawling",
47
+ YL_LOCOMOTION_OTHER: "Other",
48
+ YL_LOCOMOTION_UNKNOWN: "Unknown"
49
+ }
50
+
51
+ SensorLocations = {
52
+ YL_SENSOR_RSHOULDER: "RShoulder",
53
+ YL_SENSOR_BACK: "Back",
54
+ YL_SENSOR_RHAND: "RHand",
55
+ YL_SENSOR_WAIST: "Waist",
56
+ YL_SENSOR_CHEST: "Chest",
57
+ YL_SENSOR_UNKNOWN: "Unknown"
58
+ }
59
+
60
+ PresetMotionWR = 0
61
+ PresetMotionWRC = 1
62
+
63
+ PresetHandDisabled = 0
64
+ PresetHandEnabled = 1
65
+ PresetHandOnly = 2
66
+
67
+ PresetHeadingDynamic = 0
68
+ PresetHeadingStatic = 1
69
+
70
+ @dataclass
71
+ class YL_EEPTS_OUTPUT_DATA:
72
+
73
+ segment_count: int = 0 #number of segments (a segment refers to a single step action)
74
+ timestamp: int = 0 #in microseconds, set to end of last estimated segment
75
+ estimated_gps_longitude: float = 0 #DDMM.MMMM (positive east, negative west)
76
+ estimated_gps_latitude: float = 0 #DDMM.MMMM (positive north, negative south)
77
+ estimated_gps_altitude: float = 0 #in meters
78
+ estimated_heading_angle: float = 0 #in degrees (0 north, 90 east, 180 south, 270 west)
79
+ estimated_distance_travelled: float = 0 #in meters, since last reset (A reset is a start command)
80
+ estimated_distance_x: float = 0 #in meters since last update, along positive east/negative west (An update is a read)
81
+ estimated_distance_y: float = 0 #in meters, since last update, along positive north/negative south
82
+ estimated_distance_z: float = 0 #in meters, since last update, along positive up/negative down
83
+ estimated_locomotion_mode: int = 0 #0=idle, 1=walking, etc...
84
+ estimated_receiver_location: int = 0 #0=unknown, 1=chest, etc...
85
+ last_event_confidence: float = 1
86
+ overall_confidence: float = 1
87
+
88
+ LOCMOTION_DICT: ClassVar[dict[int, str]] = { YL_LOCOMOTION_IDLE : "Idle", YL_LOCOMOTION_WALKING : "Walking", YL_LOCOMOTION_JOGGING : "Jogging", YL_LOCOMOTION_RUNNING : "Running", YL_LOCOMOTION_CRAWLING: "Crawling", YL_LOCOMOTION_OTHER: "Other", YL_LOCOMOTION_UNKNOWN: "Unknown" }
89
+ LOCATION_DICT: ClassVar[dict[int, str]] = { YL_SENSOR_UNKNOWN : "Unknown", YL_SENSOR_CHEST : "Chest", YL_SENSOR_WAIST : "Waist", YL_SENSOR_BACK : "Back", YL_SENSOR_RHAND : "RHand", YL_SENSOR_RSHOULDER : "RShoulder" }
90
+
91
+ def clone(self, other: "YL_EEPTS_OUTPUT_DATA"):
92
+ self.segment_count = other.segment_count
93
+ self.timestamp = other.timestamp
94
+ self.estimated_gps_longitude = other.estimated_gps_longitude
95
+ self.estimated_gps_latitude = other.estimated_gps_latitude
96
+ self.estimated_gps_altitude = other.estimated_gps_altitude
97
+ self.estimated_heading_angle = other.estimated_heading_angle
98
+ self.estimated_distance_travelled = other.estimated_distance_travelled
99
+ self.estimated_distance_x = other.estimated_distance_x
100
+ self.estimated_distance_y = other.estimated_distance_y
101
+ self.estimated_distance_z = other.estimated_distance_z
102
+ self.estimated_locomotion_mode = other.estimated_locomotion_mode
103
+ self.estimated_receiver_location = other.estimated_receiver_location
104
+ self.last_event_confidence = other.last_event_confidence
105
+ self.overall_confidence = other.overall_confidence
106
+
107
+ def get_locomotion_string(self):
108
+ return YL_EEPTS_OUTPUT_DATA.LOCMOTION_DICT[self.estimated_locomotion_mode]
109
+
110
+ def get_location_string(self):
111
+ return YL_EEPTS_OUTPUT_DATA.LOCATION_DICT[self.estimated_receiver_location]
112
+
113
+ def __str__(self):
114
+ return f"{self.segment_count},{self.timestamp}," \
115
+ f"{self.estimated_gps_longitude},{self.estimated_gps_latitude}," \
116
+ f"{self.estimated_gps_altitude},{self.estimated_heading_angle}," \
117
+ f"{self.estimated_distance_travelled},{self.estimated_distance_x}," \
118
+ f"{self.estimated_distance_y},{self.estimated_distance_z}," \
119
+ f"{self.estimated_locomotion_mode},{self.estimated_receiver_location}"
120
+
121
+ def print_fancy(self):
122
+ print("Segment Count:", self.segment_count)
123
+ print("Timestamp:", self.timestamp)
124
+ print("Longitude:", self.estimated_gps_longitude)
125
+ print("Latitude:", self.estimated_gps_latitude)
126
+ print("Altitude:", self.estimated_gps_altitude)
127
+ print("Heading:", self.estimated_heading_angle)
128
+ print("Distance Travelled:", self.estimated_distance_travelled)
129
+ print("Delta_X:", self.estimated_distance_x)
130
+ print("Delta_Y:", self.estimated_distance_y)
131
+ print("Delta_Z:", self.estimated_distance_z)
132
+ print("Locomotion:", self.get_locomotion_string())
133
+ print("Sensor Location:", self.get_location_string())
134
+ print("Confidence:", self.last_event_confidence)
135
+ print("Overall Confidence:", self.overall_confidence)
136
+
137
+ @dataclass
138
+ class SensorData:
139
+ accel_x : float
140
+ accel_y : float
141
+ accel_z : float
142
+
143
+ gyro_x : float
144
+ gyro_y : float
145
+ gyro_z : float
146
+
147
+ mag_x : float
148
+ mag_y : float
149
+ mag_z : float
150
+
151
+ accel : list[float] = field(init=False, default_factory=list)
152
+ gyro : list[float] = field(init=False, default_factory=list)
153
+ mag : list[float] = field(init=False, default_factory=list)
154
+
155
+ def __post_init__(self):
156
+ self.accel = [self.accel_x, self.accel_y, self.accel_z]
157
+ self.gyro = [self.gyro_x, self.gyro_y, self.gyro_z]
158
+ self.mag = [self.mag_x, self.mag_y, self.mag_z]
159
+
160
+ def __str__(self):
161
+ return f"{self.accel_x},{self.accel_y},{self.accel_z}," \
162
+ f"{self.gyro_x},{self.gyro_y}, {self.gyro_z}," \
163
+ f"{self.mag_x},{self.mag_y},{self.mag_z}"
164
+
165
+ def print_fancy(self):
166
+ print("Accel:", self.accel)
167
+ print("Gyro:", self.gyro)
168
+ print("Mag:", self.mag)
169
+
170
+ @dataclass
171
+ class DebugMessage:
172
+ level: int = 0
173
+ module: int = 0
174
+ msg: str = ""
175
+
176
+ def get_display_str(self):
177
+ return f"Level: {self.level} Module: {self.module} {self.msg}"
178
+
179
+ @dataclass
180
+ class Segment:
181
+ #Output Structure
182
+ segment_count: int = 0
183
+ timestamp: int = 0
184
+ estimated_gps_longitude: float = 0
185
+ estimated_gps_latitude: float = 0
186
+ estimated_gps_altitude: float = 0
187
+ estimated_heading_angle: float = 0
188
+ estimated_distance_travelled: float = 0
189
+ estimated_distance_x: float = 0
190
+ estimated_distance_y: float = 0
191
+ estimated_distance_z: float = 0
192
+ estimated_locomotion_mode: int = 0
193
+ estimated_receiver_location: int = 0
194
+ last_event_confidence: float = 0
195
+ overall_confidence: float = 0
196
+
197
+ #Segment Info Structure
198
+ start_global_index: int = 0
199
+ end_global_index: int = 0
200
+ f_start_index_offset: float = 0
201
+ f_end_index_offset: float = 0
202
+ len: int = 0
203
+ f_len: float = 0
204
+ dir: int = 0
205
+
206
+ #Debug Messages:
207
+ debug_msgs: list[DebugMessage] = field(default_factory=lambda: [])
208
+
209
+ @classmethod
210
+ def from_only_output_obj(cls, output: YL_EEPTS_OUTPUT_DATA):
211
+ new_obj = cls()
212
+ output_fields = [f for f in dir(output) if not f.startswith('__') and not callable(getattr(output, f))]
213
+ for field in output_fields: #Can NOT just assign. That will cause unintentional problems if ever saving segment info
214
+ setattr(new_obj, field, getattr(output, field))
215
+
216
+ return new_obj
217
+
218
+ def __str__(self):
219
+ s = ""
220
+ for field in fields(self):
221
+ attr = getattr(self, field.name)
222
+ if field.name == "estimated_locomotion_mode":
223
+ s += f"{field.name}: {LocomotionModes[attr]}\n"
224
+ elif field.name == "estimated_receiver_location":
225
+ s += f"{field.name}: {SensorLocations[attr]}\n"
226
+ else:
227
+ s += f"{field.name}: {attr}\n"
228
+ return s
@@ -0,0 +1,258 @@
1
+ from yostlabs.tss3.api import *
2
+ import math
3
+
4
+ class ThreespaceBufferInputStream(ThreespaceInputStream):
5
+ """
6
+ Default Input Stream for the binary parser.
7
+ Ignores timeout since this is only used synchronously for now.
8
+ """
9
+
10
+ def __init__(self):
11
+ self.buffer = bytearray()
12
+
13
+ """Reads specified number of bytes."""
14
+ def read(self, num_bytes) -> bytes:
15
+ num_bytes = min(len(self.buffer), num_bytes)
16
+ result = self.buffer[:num_bytes]
17
+ del self.buffer[:num_bytes]
18
+ #self.buffer = self.buffer[num_bytes:]
19
+ return result
20
+
21
+ def read_all(self):
22
+ return self.read(self.length)
23
+
24
+ def read_until(self, expected: bytes) -> bytes:
25
+ if expected not in self.buffer:
26
+ return self.read_all()
27
+
28
+ length = self.buffer.index(expected) + len(expected)
29
+ result = self.buffer[:length]
30
+ del self.buffer[:length]
31
+ #self.buffer = self.buffer[length:]
32
+ raise result
33
+
34
+ """Allows reading without removing the data from the buffer"""
35
+ def peek(self, num_bytes) -> bytes:
36
+ num_bytes = min(len(self.buffer), num_bytes)
37
+ return self.buffer[:num_bytes]
38
+
39
+ def peek_until(self, expected: bytes, max_length=None) -> bytes:
40
+ if expected in self.buffer: #Read until the expected
41
+ length = self.buffer.index(expected) + len(expected)
42
+ if max_length is not None and length > max_length:
43
+ length = max_length
44
+ return self.buffer[:length]
45
+
46
+ #There is no expected, so read as far as possible
47
+ length = len(self.buffer)
48
+ if max_length is not None:
49
+ length = min(length, max_length)
50
+ return self.buffer[:length]
51
+
52
+ def readline(self) -> bytes:
53
+ return self.read_until(b"\n")
54
+
55
+ def peekline(self, max_length=None) -> bytes:
56
+ return self.peek_until(b"\n", max_length=max_length)
57
+
58
+ def insert(self, data: bytes):
59
+ self.buffer.extend(data)
60
+
61
+ @property
62
+ def length(self) -> int:
63
+ return len(self.buffer)
64
+
65
+ @property
66
+ def timeout(self) -> float:
67
+ raise NotImplementedError()
68
+
69
+ @timeout.setter
70
+ def timeout(self, timeout: float):
71
+ raise NotImplementedError()
72
+
73
+ class ThreespaceBinaryParser:
74
+ """
75
+ A class that can be used to parse a stream of binary data
76
+ that could contain multiple different command responses and validates
77
+ the responses to handle misalignment/data corruption.
78
+
79
+ Requires all expected responses to have the same header enabled.
80
+
81
+ The header should contain the cmd_echo, checksum, and data_length fields
82
+ for full functionality. The lack of any of those fields could limit functionality
83
+ of the parser.
84
+
85
+ If cmd_echo is missing, only one command can be registered with the binary parser as it has no way
86
+ of knowing what of verifying what the current incoming response is.
87
+
88
+ If checksum is missing, data integrity can not be checked.
89
+
90
+ If data_length is missing, commands that do not return a static length may cause blocking operations
91
+ or errors while parsing. (These are planned to be fixed in a future version)
92
+
93
+ NOTE: For speed, a custom implementation will be better then this parser class. This parser handles allowing
94
+ multiple commands as well as data validation and misalignment correction. It also formats the data into the ThreespaceCmdResult
95
+ response type. The overhead added because of all these additional checks/calculations can add a significant amount of time
96
+ to processing binary data compared to just reading a known amount of data and instantly unpacking it to a tuple in the desired format.
97
+ """
98
+
99
+ COMMAND_EXCEPTIONS = [84, 177] #getStreamingBatch and fileReadBytes need additional info and so need registered via the
100
+
101
+ def __init__(self, data_stream: ThreespaceInputStream = None, verbose=False):
102
+ """
103
+ Parameters
104
+ ----------
105
+ data_stream - (optional) The data stream to use with the Binary Parser. If not supplied, will default to a new ThreespaceBufferInputStream
106
+ """
107
+ self.data_stream = data_stream
108
+ self.registered_commands: dict[int,ThreespaceCommand] = {}
109
+
110
+ if self.data_stream is None:
111
+ self.data_stream = ThreespaceBufferInputStream()
112
+ self.header_info = None
113
+
114
+ self.__parsing_header: ThreespaceHeader = None #Used to optimize preventing reading to much by cacheing the header seperately from the cmd data
115
+ self.__parsing_command: ThreespaceCommand = None
116
+ self.__parsing_msg_length: int = None #Used seperately from the __parsing_header so can handle msg lengths that are static without modifying the header
117
+
118
+ self.misaligned = False
119
+ self.verbose = verbose
120
+
121
+ def register_command(self, cmd: int|ThreespaceCommand, **kwargs):
122
+ """
123
+ Registers the given cmd number/cmd with the binary parser.
124
+
125
+ Some commands may require additional information:
126
+ stream_slots - list[int] Required when registering command 84 (getStreamingBatch) a list of command numbers that are being streamed.
127
+ read_size - 'auto' or int Required when registering a command that requires a given length such as fileReadBytes. If 'auto' will use the header length to determine length.
128
+ """
129
+ if isinstance(cmd, int):
130
+ cmd = threespaceCommandGet(cmd)
131
+ if cmd is None:
132
+ raise ValueError(f"Invalid Cmd {cmd}")
133
+
134
+ if cmd.info.num in self.registered_commands:
135
+ return False
136
+
137
+ #These command types are special and need additional info
138
+ if cmd.info.num == THREESPACE_FILE_READ_BYTES_COMMAND_NUM:
139
+ if "read_size" not in kwargs:
140
+ raise ValueError("Missing arguement 'read_size' when registering the fileReadBytes command with the binary parser")
141
+ raise NotImplementedError("The fileReadBytes command has yet to be implemented for the ThreespaceBinaryParser")
142
+ elif cmd.info.num == THREESPACE_GET_STREAMING_BATCH_COMMAND_NUM:
143
+ if "stream_slots" not in kwargs:
144
+ raise ValueError("Missing arguement 'stream_slots' when registering the getStreamingBatch command with the binary parser")
145
+ cmd = ThreespaceGetStreamingBatchCommand(kwargs['stream_slots'])
146
+
147
+ self.registered_commands[cmd.info.num] = cmd
148
+ return True
149
+
150
+ def unregister_command(self, cmd: int|ThreespaceCommand):
151
+ if cmd.info.num not in self.registered_commands:
152
+ return False
153
+ del self.registered_commands[cmd.info.num]
154
+ return True
155
+
156
+ def set_header(self, header_info: ThreespaceHeaderInfo):
157
+ self.header_info = header_info
158
+
159
+ def insert_data(self, data: bytes):
160
+ """
161
+ Add the given data to the default ThreespaceBufferInputStream.
162
+ This method will raise an exception if used on a different type of InputStream
163
+ """
164
+ if not isinstance(self.data_stream, ThreespaceBufferInputStream):
165
+ raise Exception("Insert data with Binary Parser only valid when using the default data_stream")
166
+ self.data_stream.insert(data)
167
+
168
+ def parse_message(self) -> ThreespaceCmdResult:
169
+ if self.__parsing_header is None:
170
+ self.__parse_header()
171
+
172
+ if self.__parsing_command is None:
173
+ return None
174
+
175
+ return self.__parse_command()
176
+
177
+ def __parse_header(self):
178
+ if self.data_stream.length < self.header_info.size:
179
+ return
180
+
181
+ header = self.data_stream.peek(self.header_info.size)
182
+ header = ThreespaceHeader.from_bytes(header, self.header_info)
183
+
184
+ cmd_found = False
185
+ if self.header_info.echo_enabled: #Search for the command to parse
186
+ for command in self.registered_commands.values():
187
+ if header.echo != command.info.num: continue
188
+
189
+ #Command matches! Attempt to parse
190
+ self.__parsing_command = command
191
+ self.__parsing_header = header
192
+
193
+ cmd_found = True
194
+ else: #Can only parse one command
195
+ if len(self.registered_commands) > 1:
196
+ raise Exception("Only one command type can be parsed when the 'cmd echo' is not enabled in the header")
197
+ self.__parsing_command = list(self.registered_commands.values())[0]
198
+ self.__parsing_header = header
199
+ cmd_found = True
200
+
201
+ if cmd_found:
202
+ if self.header_info.length_enabled:
203
+ self.__parsing_msg_length = self.__parsing_header.length
204
+ else:
205
+ self.__parsing_msg_length = self.__parsing_command.info.out_size
206
+ return
207
+
208
+ #This header is not related to any command, so it needs skipped
209
+ if self.verbose and not self.misaligned:
210
+ print("Unexpected header:", header)
211
+ self.misaligned = True
212
+ self.data_stream.read(1)
213
+
214
+ def __peek_checksum(self):
215
+ header_len = len(self.__parsing_header.raw_binary)
216
+ data = self.data_stream.peek(header_len + self.__parsing_msg_length)[header_len:]
217
+ checksum = sum(data) % 256
218
+ return checksum == self.__parsing_header.checksum
219
+
220
+ def __parse_command(self):
221
+ #Not enough data to parse yet
222
+ if self.data_stream.length < self.header_info.size + self.__parsing_msg_length:
223
+ return None
224
+
225
+ if self.header_info.checksum_enabled and not math.isnan(self.__parsing_msg_length): #Can validate checksum before parsing
226
+ print("Pre validating checksum")
227
+ if not self.__peek_checksum():
228
+ #Data corruption/Misalignment error
229
+ if self.verbose and not self.misaligned:
230
+ print("Checksum mismatch for command", self.__parsing_command.info.num)
231
+ self.misaligned = True
232
+ self.data_stream.read(1)
233
+ self.__parsing_command = None
234
+ self.__parsing_header = None
235
+ return None
236
+
237
+ #Header and pre validation checksum checks out! Now just parse the actual command result and return it
238
+ header = self.__parsing_header
239
+ self.data_stream.read(len(header.raw_binary)) #Skip these bytes since they are already parsed
240
+ result, raw = self.__parsing_command.read_command(self.data_stream)
241
+
242
+ #Validate checksum if couldn't pre-validate due to unknown message length
243
+ if math.isnan(self.__parsing_msg_length) and self.header_info.checksum_enabled:
244
+ checksum = sum(raw) % 256
245
+ if checksum != header.checksum:
246
+ if self.verbose and not self.misaligned:
247
+ print("Checksum mismatch for command", self.__parsing_command.info.num)
248
+ self.misaligned = True
249
+ self.__parsing_command = None
250
+ self.__parsing_header = None
251
+ return None
252
+
253
+ #Reset and return
254
+ self.__parsing_header = None
255
+ self.__parsing_command = None
256
+ self.misaligned = False
257
+ return ThreespaceCmdResult(result, header, data_raw_binary=raw)
258
+
@@ -0,0 +1,166 @@
1
+ import math
2
+
3
+ def vec_len(vector: list[float|int]):
4
+ return sum(v ** 2 for v in vector) ** 0.5
5
+
6
+ def vec_dot(a: list[float], b: list[float]):
7
+ return sum(a[i] * b[i] for i in range(len(a)))
8
+
9
+ def vec_cross(a: list[float], b: list[float]):
10
+ cross = [0, 0, 0]
11
+ cross[0] = a[1] * b[2] - a[2] * b[1]
12
+ cross[1] = a[2] * b[0] - a[0] * b[2]
13
+ cross[2] = a[0] * b[1] - a[1] * b[0]
14
+ return cross
15
+
16
+ def vec_normalize(vec: list[float]):
17
+ l = vec_len(vec)
18
+ if l == 0:
19
+ return vec
20
+ return [v / l for v in vec]
21
+
22
+ def quat_mul(a: list[float], b: list[float]):
23
+ out = [0, 0, 0, 0]
24
+ x = 0; y = 1; z = 2; w = 3
25
+ out[w] = a[w]*b[w] - a[x]*b[x] - a[y]*b[y] - a[z]*b[z]
26
+ out[x] = a[w]*b[x] + a[x]*b[w] + a[y]*b[z] - a[z]*b[y]
27
+ out[y] = a[w]*b[y] + a[y]*b[w] + a[z]*b[x] - a[x]*b[z]
28
+ out[z] = a[w]*b[z] + a[z]*b[w] + a[x]*b[y] - a[y]*b[x]
29
+ return out
30
+
31
+ #Rotates quaternion b by quaternion a, it does not combine them
32
+ def quat_rotate(a: list[float], b: list[float]):
33
+ inv = a.copy()
34
+ inv[0] *= -1
35
+ inv[1] *= -1
36
+ inv[2] *= -1
37
+ axis = [b[0], b[1], b[2], 0]
38
+ halfway = quat_mul(a, axis)
39
+ final = quat_mul(halfway, inv)
40
+ return [*final[:3], b[3]]
41
+
42
+ def quat_inverse(quat: list[float]):
43
+ return [-quat[0], -quat[1], -quat[2], quat[3]]
44
+
45
+ def quat_rotate_vec(quat: list[float], vec: list[float]):
46
+ inv = quat_inverse(quat)
47
+ tmp = [vec[0], vec[1], vec[2], 0]
48
+ halfway = quat_mul(quat, tmp)
49
+ final = quat_mul(halfway, inv)
50
+ return [final[0], final[1], final[2]]
51
+
52
+ def is_right_handed(order: str, negations: list[bool]):
53
+ right_handed = order.lower() in ("xzy", "yxz", "zyx")
54
+ for i in range(3):
55
+ if negations[i]:
56
+ right_handed = not right_handed
57
+ return right_handed
58
+
59
+ def axis_to_unit_vector(axis: str):
60
+ axis = axis.lower()
61
+ if axis == 'x' or axis == 0: return [1, 0, 0]
62
+ if axis == 'y' or axis == 1: return [0, 1, 0]
63
+ if axis == 'z' or axis == 2: return [0, 0, 1]
64
+
65
+ def angles_to_quaternion(angles: list[float], order: str, degrees=True):
66
+ quat = [0, 0, 0, 1]
67
+ for i in range(len(angles)):
68
+ axis = order[i]
69
+ angle = angles[i]
70
+ if degrees:
71
+ angle = math.radians(angle)
72
+ unit_vec = axis_to_unit_vector(axis)
73
+ w = math.cos(angle / 2)
74
+ imaginary = math.sin(angle / 2)
75
+ unit_vec = [v * imaginary for v in unit_vec]
76
+ angle_quat = [*unit_vec, w]
77
+ #print(f"{axis} {angle} {angle_quat}")
78
+ quat = quat_mul(quat, angle_quat)
79
+ return quat
80
+
81
+ def quat_from_axis_angle(axis: list[float], angle: float):
82
+ imaginary = math.sin(angle / 2)
83
+ quat = [imaginary * v for v in axis]
84
+ quat.append(math.cos(angle / 2))
85
+ return quat
86
+
87
+ def quat_from_two_vectors(forward: list[float], down: list[float]):
88
+ """
89
+ This function requires two orthogonal vectors to work
90
+ """
91
+ forward_reference = [0, 0, 1]
92
+ down_reference = [0, -1, 0]
93
+
94
+ forward = vec_normalize(forward)
95
+ down = vec_normalize(down)
96
+
97
+ #Create the first rotation to align the forward axis
98
+ axis_of_rotation = vec_cross(forward_reference, forward)
99
+ axis_of_rotation = vec_normalize(axis_of_rotation)
100
+ if not any(abs(v) > 0 for v in axis_of_rotation):
101
+ axis_of_rotation = down_reference #This is just a direct 180 degree rotation around any orthogonal axis, so just use the down_ref
102
+ dot = min(1, max(-1, vec_dot(forward_reference, forward)))
103
+ angle1 = math.acos(dot)
104
+ imaginary = math.sin(angle1/2)
105
+ quat = [v * imaginary for v in axis_of_rotation] #XYZ
106
+ quat.append(math.cos(angle1/2)) #W
107
+
108
+ #Update the reference to figure out where it is after the turn
109
+ down_reference = quat_rotate_vec(quat, down_reference)
110
+
111
+ #find the rotation to make the remaining reference align with its given value
112
+ axis_of_rotation = vec_cross(down_reference, down)
113
+ axis_of_rotation = vec_normalize(axis_of_rotation)
114
+ if not any(abs(v) > 0 for v in axis_of_rotation):
115
+ axis_of_rotation = forward #Rotate along the final forward vector until up is aligned
116
+ dot = min(1, max(-1, vec_dot(down_reference, down)))
117
+ angle2 = math.acos(dot)
118
+ imaginary = math.sin(angle2/2)
119
+ rotation = [v * imaginary for v in axis_of_rotation] #XYZ
120
+ rotation.append(math.cos(angle2/2)) #W
121
+
122
+ quat = quat_mul(rotation, quat)
123
+ return quat
124
+
125
+ def quaternion_to_3x3_rotation_matrix(quat):
126
+ """
127
+ Convert a quaternion in form x, y, z, w to a rotation matrix
128
+ """
129
+ x, y, z, w = quat
130
+
131
+ fTx = 2.0 * x
132
+ fTy = 2.0 * y
133
+ fTz = 2.0 * z
134
+ fTwx = fTx * w
135
+ fTwy = fTy * w
136
+ fTwz = fTz * w
137
+ fTxx = fTx * x
138
+ fTxy = fTy * x
139
+ fTxz = fTz * x
140
+ fTyy = fTy * y
141
+ fTyz = fTz * y
142
+ fTzz = fTz * z
143
+
144
+ out = [0] * 9
145
+ out[0] = 1.0-(fTyy+fTzz)
146
+ out[1] = fTxy-fTwz
147
+ out[2] = fTxz+fTwy
148
+ out[3] = fTxy+fTwz
149
+ out[4] = 1.0-(fTxx+fTzz)
150
+ out[5] = fTyz-fTwx
151
+ out[6] = fTxz-fTwy
152
+ out[7] = fTyz + fTwx
153
+ out[8] = 1.0 - (fTxx+fTyy)
154
+
155
+ return [
156
+ [out[0], out[1], out[2]],
157
+ [out[3], out[4], out[5]],
158
+ [out[6], out[7], out[8]],
159
+ ]
160
+
161
+ def quaternion_global_to_local(quat, vec):
162
+ inverse = quat_inverse(quat)
163
+ return quat_rotate_vec(inverse, vec)
164
+
165
+ def quaternion_local_to_global(quat, vec):
166
+ return quat_rotate_vec(quat, vec)