hazy-frames 0.1.0__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.
- hazy/__init__.py +29 -0
- hazy/constants.py +11 -0
- hazy/frame.py +494 -0
- hazy/primitives.py +834 -0
- hazy/utils.py +60 -0
- hazy_frames-0.1.0.dist-info/METADATA +253 -0
- hazy_frames-0.1.0.dist-info/RECORD +9 -0
- hazy_frames-0.1.0.dist-info/WHEEL +4 -0
- hazy_frames-0.1.0.dist-info/licenses/LICENSE +21 -0
hazy/primitives.py
ADDED
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
"""Geometric primitives with frame awareness for coordinate system transformations.
|
|
2
|
+
|
|
3
|
+
This module provides Point and Vector classes that carry frame information and
|
|
4
|
+
support arithmetic operations with proper type semantics.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Self, TypeVar, overload
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from hazy.constants import VSMALL, VVSMALL
|
|
15
|
+
from hazy.utils import all_same_type, check_same_frame
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from numpy.typing import ArrayLike, NDArray
|
|
19
|
+
|
|
20
|
+
from hazy import Frame
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
24
|
+
HANDLED_ARRAY_FUNCTIONS: dict[Callable[..., Any], Callable[..., Any]] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def implements(numpy_function: Callable[..., Any]) -> Callable[[F], F]:
|
|
28
|
+
"""Register a function as implementation for a NumPy array function."""
|
|
29
|
+
|
|
30
|
+
def decorator(func: F) -> F:
|
|
31
|
+
HANDLED_ARRAY_FUNCTIONS[numpy_function] = func
|
|
32
|
+
return func
|
|
33
|
+
|
|
34
|
+
return decorator
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GeometricPrimitive:
|
|
38
|
+
"""Base class for geometric primitives (Point and Vector) with frame awareness.
|
|
39
|
+
|
|
40
|
+
Uses homogeneous coordinates (x, y, z, w) for unified transformation handling.
|
|
41
|
+
Points have w=1, Vectors have w=0.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, x: float, y: float, z: float, w: float, frame: Frame):
|
|
45
|
+
"""Initialize geometric primitive in homogeneous coordinates.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
x: X coordinate
|
|
49
|
+
y: Y coordinate
|
|
50
|
+
z: Z coordinate
|
|
51
|
+
w: Homogeneous coordinate (1 for Point, 0 for Vector)
|
|
52
|
+
frame: Reference frame for this primitive
|
|
53
|
+
"""
|
|
54
|
+
self._homogeneous = np.array([x, y, z, w], dtype=float)
|
|
55
|
+
self.frame = frame
|
|
56
|
+
|
|
57
|
+
def __array__(self, dtype=None, copy=None) -> np.ndarray:
|
|
58
|
+
"""Return Cartesian coordinates for numpy operations.
|
|
59
|
+
|
|
60
|
+
Enables usage like: np.array(point) or np.add(point1, point2)
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
dtype: Desired array data type
|
|
64
|
+
copy: Whether to copy the data
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Numpy array of Cartesian coordinates
|
|
68
|
+
"""
|
|
69
|
+
coords = self._homogeneous[:3]
|
|
70
|
+
if dtype is not None:
|
|
71
|
+
coords = coords.astype(dtype)
|
|
72
|
+
return coords if copy is False else coords.copy()
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def x(self) -> float:
|
|
76
|
+
"""X coordinate."""
|
|
77
|
+
return self._homogeneous[0]
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def y(self) -> float:
|
|
81
|
+
"""Y coordinate."""
|
|
82
|
+
return self._homogeneous[1]
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def z(self) -> float:
|
|
86
|
+
"""Z coordinate."""
|
|
87
|
+
return self._homogeneous[2]
|
|
88
|
+
|
|
89
|
+
def __eq__(self, value: object) -> bool:
|
|
90
|
+
if isinstance(value, GeometricPrimitive):
|
|
91
|
+
self_global = self.to_global()
|
|
92
|
+
value_global = value.to_global()
|
|
93
|
+
return np.allclose(
|
|
94
|
+
self_global._homogeneous, value_global._homogeneous, atol=VVSMALL
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"Can not compare {self} of type {self.__class__.__qualname__} "
|
|
99
|
+
f"with onject of type {type(value)}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def __getitem__(self, index: int) -> float:
|
|
103
|
+
"""Access coordinates by index: primitive[0] for x, primitive[1] for y, etc."""
|
|
104
|
+
return np.array(self)[index]
|
|
105
|
+
|
|
106
|
+
def __iter__(self):
|
|
107
|
+
"""Iterate over Cartesian coordinates."""
|
|
108
|
+
return iter(np.array(self))
|
|
109
|
+
|
|
110
|
+
def to_frame(self, target_frame: Frame) -> Self:
|
|
111
|
+
"""Transform this primitive to a different reference frame.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
target_frame: Target reference frame
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
New primitive of same type in target frame
|
|
118
|
+
"""
|
|
119
|
+
transformation = self.frame.transform_to(target=target_frame)
|
|
120
|
+
x, y, z, w = transformation @ self._homogeneous
|
|
121
|
+
|
|
122
|
+
if isinstance(self, Point):
|
|
123
|
+
# Points (w=1): normalize by w after transformation
|
|
124
|
+
return type(self)(x=x / w, y=y / w, z=z / w, w=1.0, frame=target_frame)
|
|
125
|
+
else:
|
|
126
|
+
# Vectors (w=0): do not normalize, w stays 0
|
|
127
|
+
return type(self)(x=x, y=y, z=z, w=0.0, frame=target_frame)
|
|
128
|
+
|
|
129
|
+
def to_global(self) -> Self:
|
|
130
|
+
"""Transform to the root frame of this primitive's hierarchy.
|
|
131
|
+
|
|
132
|
+
For frames with parents, this transforms to the top-most parent.
|
|
133
|
+
For orphan frames, this returns coordinates in the orphan frame itself.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Primitive in root frame coordinates
|
|
137
|
+
"""
|
|
138
|
+
return self.to_frame(target_frame=self.frame.root)
|
|
139
|
+
|
|
140
|
+
def __repr__(self) -> str:
|
|
141
|
+
return (
|
|
142
|
+
f"{self.__class__.__qualname__}("
|
|
143
|
+
f"x={self.x}, "
|
|
144
|
+
f"y={self.y}, "
|
|
145
|
+
f"z={self.z}, "
|
|
146
|
+
f"frame={self.frame.name})"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
|
|
150
|
+
"""Handle numpy universal functions to maintain type consistency.
|
|
151
|
+
|
|
152
|
+
This prevents numpy from converting our custom types to plain arrays
|
|
153
|
+
when using operators like *, +, -, etc.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
# For binary operations, handle scalar multiplication/division for Vectors only
|
|
157
|
+
if method == "__call__" and len(inputs) == 2:
|
|
158
|
+
# Identify which input is the geometric primitive
|
|
159
|
+
geom_idx = 0 if isinstance(inputs[0], GeometricPrimitive) else 1
|
|
160
|
+
other_idx = 1 - geom_idx
|
|
161
|
+
geom_primitive = inputs[geom_idx]
|
|
162
|
+
other = inputs[other_idx]
|
|
163
|
+
|
|
164
|
+
# Handle multiplication/division with scalars (only for Vector)
|
|
165
|
+
if (
|
|
166
|
+
ufunc in (np.multiply, np.divide, np.true_divide)
|
|
167
|
+
and np.isscalar(other)
|
|
168
|
+
and isinstance(geom_primitive, Vector)
|
|
169
|
+
):
|
|
170
|
+
if ufunc == np.multiply:
|
|
171
|
+
return geom_primitive.__mul__(other)
|
|
172
|
+
elif ufunc in (np.divide, np.true_divide) and geom_idx == 0:
|
|
173
|
+
return geom_primitive.__truediv__(other)
|
|
174
|
+
|
|
175
|
+
# delegate arithmetic to __dunder__ methods
|
|
176
|
+
if ufunc in (
|
|
177
|
+
np.add,
|
|
178
|
+
np.subtract,
|
|
179
|
+
np.multiply,
|
|
180
|
+
np.divide,
|
|
181
|
+
np.true_divide,
|
|
182
|
+
np.negative,
|
|
183
|
+
):
|
|
184
|
+
return NotImplemented
|
|
185
|
+
|
|
186
|
+
# delegate comparison to __dunder__ methods
|
|
187
|
+
if ufunc in (
|
|
188
|
+
np.equal,
|
|
189
|
+
np.not_equal,
|
|
190
|
+
np.less,
|
|
191
|
+
np.less_equal,
|
|
192
|
+
np.greater,
|
|
193
|
+
np.greater_equal,
|
|
194
|
+
):
|
|
195
|
+
return NotImplemented
|
|
196
|
+
|
|
197
|
+
# Rounding operations - preserve geometric type
|
|
198
|
+
if (
|
|
199
|
+
ufunc in (np.floor, np.ceil, np.trunc, np.rint, np.fix)
|
|
200
|
+
and method == "__call__"
|
|
201
|
+
):
|
|
202
|
+
coords = getattr(ufunc, method)(np.array(inputs[0]), **kwargs)
|
|
203
|
+
result = inputs[0].copy()
|
|
204
|
+
result._homogeneous[:3] = coords
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
# Type queries - return boolean arrays
|
|
208
|
+
if (
|
|
209
|
+
ufunc in (np.isnan, np.isinf, np.isfinite, np.signbit)
|
|
210
|
+
and method == "__call__"
|
|
211
|
+
):
|
|
212
|
+
return getattr(ufunc, method)(np.array(inputs[0]), **kwargs)
|
|
213
|
+
|
|
214
|
+
# Absolute value - special handling
|
|
215
|
+
if ufunc == np.absolute and method == "__call__":
|
|
216
|
+
if isinstance(inputs[0], Vector):
|
|
217
|
+
return inputs[0].magnitude
|
|
218
|
+
raise TypeError(
|
|
219
|
+
f"abs({inputs[0].__class__.__qualname__}) is geometrically undefined. "
|
|
220
|
+
"Use np.abs(np.array(point)) for coordinate-wise absolute values."
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Everything else explicit error:
|
|
224
|
+
# For other ufuncs, convert to array and return array result
|
|
225
|
+
# This handles operations where maintaining custom type doesn't make sense
|
|
226
|
+
raise TypeError(
|
|
227
|
+
f"ufunc {ufunc.__name__} not supported for {self.__class__.__qualname__}.\n"
|
|
228
|
+
f"Convert explicitly: np.{ufunc.__name__}(np.array(obj))"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def __array_function__(self, func, types, inputs, kwargs):
|
|
232
|
+
if func not in HANDLED_ARRAY_FUNCTIONS:
|
|
233
|
+
return NotImplemented
|
|
234
|
+
|
|
235
|
+
if not all(
|
|
236
|
+
issubclass(t, (GeometricPrimitive, np.ndarray)) or t is type(None)
|
|
237
|
+
for t in types
|
|
238
|
+
):
|
|
239
|
+
return NotImplemented
|
|
240
|
+
|
|
241
|
+
return HANDLED_ARRAY_FUNCTIONS[func](*inputs, **kwargs)
|
|
242
|
+
|
|
243
|
+
def copy(self) -> Self:
|
|
244
|
+
"""Create a copy of this geometric primitive in the same frame."""
|
|
245
|
+
return type(self)(
|
|
246
|
+
self.x, self.y, self.z, frame=self.frame, w=self._homogeneous[3]
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def round(self, decimals: int = 0) -> Self:
|
|
250
|
+
out = self.copy()
|
|
251
|
+
out._homogeneous[:3] = np.round(self._homogeneous[:3], decimals=decimals)
|
|
252
|
+
return out
|
|
253
|
+
|
|
254
|
+
def round_(self, decimals: int = 0) -> Self:
|
|
255
|
+
self._homogeneous[:3] = np.round(self._homogeneous[:3], decimals=decimals)
|
|
256
|
+
return self
|
|
257
|
+
|
|
258
|
+
def clip(self, a_min: float = -np.inf, a_max: float = np.inf) -> Self:
|
|
259
|
+
out = self.copy()
|
|
260
|
+
out._homogeneous[:3] = np.clip(self._homogeneous[:3], a_min=a_min, a_max=a_max)
|
|
261
|
+
return out
|
|
262
|
+
|
|
263
|
+
def clip_(self, a_min: float = -np.inf, a_max: float = np.inf) -> Self:
|
|
264
|
+
self._homogeneous[:3] = np.clip(self._homogeneous[:3], a_min=a_min, a_max=a_max)
|
|
265
|
+
return self
|
|
266
|
+
|
|
267
|
+
def floor(self) -> Self:
|
|
268
|
+
copy = self.copy()
|
|
269
|
+
copy._homogeneous[:3] = np.floor(copy._homogeneous[:3])
|
|
270
|
+
return copy
|
|
271
|
+
|
|
272
|
+
def floor_(self) -> Self:
|
|
273
|
+
self._homogeneous[:3] = np.floor(self._homogeneous[:3])
|
|
274
|
+
return self
|
|
275
|
+
|
|
276
|
+
def fix(self) -> Self:
|
|
277
|
+
copy = self.copy()
|
|
278
|
+
copy._homogeneous[:3] = np.fix(copy._homogeneous[:3])
|
|
279
|
+
return copy
|
|
280
|
+
|
|
281
|
+
def fix_(self) -> Self:
|
|
282
|
+
self._homogeneous[:3] = np.fix(self._homogeneous[:3])
|
|
283
|
+
return self
|
|
284
|
+
|
|
285
|
+
def ceil(self) -> Self:
|
|
286
|
+
copy = self.copy()
|
|
287
|
+
copy._homogeneous[:3] = np.ceil(copy._homogeneous[:3])
|
|
288
|
+
return copy
|
|
289
|
+
|
|
290
|
+
def ceil_(self) -> Self:
|
|
291
|
+
self._homogeneous[:3] = np.ceil(self._homogeneous[:3])
|
|
292
|
+
return self
|
|
293
|
+
|
|
294
|
+
def trunc(self) -> Self:
|
|
295
|
+
copy = self.copy()
|
|
296
|
+
copy._homogeneous[:3] = np.trunc(copy._homogeneous[:3])
|
|
297
|
+
return copy
|
|
298
|
+
|
|
299
|
+
def trunc_(self) -> Self:
|
|
300
|
+
self._homogeneous[:3] = np.trunc(self._homogeneous[:3])
|
|
301
|
+
return self
|
|
302
|
+
|
|
303
|
+
def rint(self) -> Self:
|
|
304
|
+
copy = self.copy()
|
|
305
|
+
copy._homogeneous[:3] = np.rint(copy._homogeneous[:3])
|
|
306
|
+
return copy
|
|
307
|
+
|
|
308
|
+
def rint_(self) -> Self:
|
|
309
|
+
self._homogeneous[:3] = np.rint(self._homogeneous[:3])
|
|
310
|
+
return self
|
|
311
|
+
|
|
312
|
+
@classmethod
|
|
313
|
+
def from_array(cls, array: ArrayLike, frame: Frame) -> Self:
|
|
314
|
+
array = np.asarray(array).flatten()
|
|
315
|
+
if array.shape != (4,):
|
|
316
|
+
raise ValueError(f"Expected 4 coordinates, got {array.shape}")
|
|
317
|
+
return cls(x=array[0], y=array[1], z=array[2], w=array[3], frame=frame)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class Vector(GeometricPrimitive):
|
|
321
|
+
"""Geometric vector representing direction and magnitude.
|
|
322
|
+
|
|
323
|
+
Vectors have homogeneous coordinate w=0, making them invariant to translation.
|
|
324
|
+
|
|
325
|
+
Arithmetic semantics:
|
|
326
|
+
- Vector + Vector = Vector (combine displacements)
|
|
327
|
+
- Vector - Vector = Vector (difference of displacements)
|
|
328
|
+
- Vector + Point = Point (displace position)
|
|
329
|
+
- Vector - Point = ERROR (undefined operation)
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
def __init__(self, x: float, y: float, z: float, frame: Frame, *, w=0.0):
|
|
333
|
+
"""Initialize vector in given frame.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
x: X component
|
|
337
|
+
y: Y component
|
|
338
|
+
z: Z component
|
|
339
|
+
frame: Reference frame
|
|
340
|
+
w: Homogeneous coordinate (should be 0 for vectors)
|
|
341
|
+
"""
|
|
342
|
+
super().__init__(x=x, y=y, z=z, w=w, frame=frame)
|
|
343
|
+
|
|
344
|
+
@overload
|
|
345
|
+
def __add__(self, other: Point) -> Point: ...
|
|
346
|
+
|
|
347
|
+
@overload
|
|
348
|
+
def __add__(self, other: Vector) -> Vector: ...
|
|
349
|
+
|
|
350
|
+
@overload
|
|
351
|
+
def __add__(self, other: NDArray[np.floating]) -> NDArray[np.floating]: ...
|
|
352
|
+
|
|
353
|
+
def __add__(
|
|
354
|
+
self, other: Point | Vector | NDArray[np.floating]
|
|
355
|
+
) -> Point | Vector | NDArray[np.floating]:
|
|
356
|
+
"""Add vector to another vector or point.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
other: Vector, Point, or numpy array
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
Vector if adding to Vector, Point if adding to Point
|
|
363
|
+
|
|
364
|
+
Raises:
|
|
365
|
+
RuntimeError: If frames don't match
|
|
366
|
+
"""
|
|
367
|
+
if isinstance(other, Point):
|
|
368
|
+
check_same_frame(self, other)
|
|
369
|
+
x, y, z = np.array(self) + np.array(other)
|
|
370
|
+
return Point(x, y, z, frame=self.frame)
|
|
371
|
+
elif isinstance(other, Vector):
|
|
372
|
+
check_same_frame(self, other)
|
|
373
|
+
x, y, z = np.array(self) + np.array(other)
|
|
374
|
+
return Vector(x, y, z, frame=self.frame)
|
|
375
|
+
else:
|
|
376
|
+
return other.__add__(self)
|
|
377
|
+
|
|
378
|
+
@overload
|
|
379
|
+
def __sub__(self, other: Vector) -> Vector: ...
|
|
380
|
+
|
|
381
|
+
@overload
|
|
382
|
+
def __sub__(self, other: NDArray[np.floating]) -> NDArray[np.floating]: ...
|
|
383
|
+
|
|
384
|
+
def __sub__(
|
|
385
|
+
self, other: Vector | NDArray[np.floating]
|
|
386
|
+
) -> Vector | NDArray[np.floating]:
|
|
387
|
+
"""Subtract vector from this vector.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
other: Vector or numpy array
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
Resulting vector
|
|
394
|
+
|
|
395
|
+
Raises:
|
|
396
|
+
TypeError: If attempting to subtract Point from Vector
|
|
397
|
+
RuntimeError: If frames don't match
|
|
398
|
+
"""
|
|
399
|
+
if isinstance(other, Point):
|
|
400
|
+
raise TypeError("Cannot subtract Point from Vector")
|
|
401
|
+
elif isinstance(other, Vector):
|
|
402
|
+
check_same_frame(self, other)
|
|
403
|
+
x, y, z = np.array(self) - np.array(other)
|
|
404
|
+
return Vector(x, y, z, frame=self.frame)
|
|
405
|
+
else:
|
|
406
|
+
return other.__rsub__(self)
|
|
407
|
+
|
|
408
|
+
def __mul__(self, other: float | np.generic) -> Vector:
|
|
409
|
+
if not np.isscalar(other):
|
|
410
|
+
raise TypeError(
|
|
411
|
+
f"Can only multiply with Vector by scalar, got {type(other)}"
|
|
412
|
+
)
|
|
413
|
+
if isinstance(other, complex | np.complexfloating):
|
|
414
|
+
raise TypeError(
|
|
415
|
+
f"unsupported operand __mul__ between {self.__class__.__qualname__} "
|
|
416
|
+
"and complex numbers"
|
|
417
|
+
)
|
|
418
|
+
copy = self.copy()
|
|
419
|
+
copy._homogeneous[:3] *= other
|
|
420
|
+
return copy
|
|
421
|
+
|
|
422
|
+
def __rmul__(self, other: float | np.generic) -> Vector:
|
|
423
|
+
return self.__mul__(other)
|
|
424
|
+
|
|
425
|
+
def __truediv__(self, other: float | np.generic) -> Vector:
|
|
426
|
+
"""Divide vector by scalar."""
|
|
427
|
+
if not np.isscalar(other):
|
|
428
|
+
raise TypeError(f"Can only divide Vector by scalar, got {type(other)}")
|
|
429
|
+
if other == 0:
|
|
430
|
+
raise ZeroDivisionError("Cannot divide vector by zero")
|
|
431
|
+
copy = self.copy()
|
|
432
|
+
copy._homogeneous[:3] /= other
|
|
433
|
+
return copy
|
|
434
|
+
|
|
435
|
+
@property
|
|
436
|
+
def magnitude(self) -> float:
|
|
437
|
+
return float(np.linalg.norm(self._homogeneous[:3]))
|
|
438
|
+
|
|
439
|
+
@property
|
|
440
|
+
def is_zero(self) -> bool:
|
|
441
|
+
return self.magnitude < VSMALL
|
|
442
|
+
|
|
443
|
+
def normalize(self) -> Self:
|
|
444
|
+
if self.is_zero:
|
|
445
|
+
raise RuntimeError(f"Can not normalize vector with zero length {self}")
|
|
446
|
+
self._homogeneous[:3] /= self.magnitude
|
|
447
|
+
return self
|
|
448
|
+
|
|
449
|
+
@classmethod
|
|
450
|
+
def unit_x(cls, frame: Frame) -> Vector:
|
|
451
|
+
return cls(x=1.0, y=0.0, z=0.0, frame=frame)
|
|
452
|
+
|
|
453
|
+
@classmethod
|
|
454
|
+
def unit_y(cls, frame: Frame) -> Vector:
|
|
455
|
+
return cls(x=0.0, y=1.0, z=0.0, frame=frame)
|
|
456
|
+
|
|
457
|
+
@classmethod
|
|
458
|
+
def unit_z(cls, frame: Frame) -> Vector:
|
|
459
|
+
return cls(x=0.0, y=0.0, z=1.0, frame=frame)
|
|
460
|
+
|
|
461
|
+
@classmethod
|
|
462
|
+
def nan(cls, frame: Frame) -> Vector:
|
|
463
|
+
"""Return a point at the origin of the specified coordinate system"""
|
|
464
|
+
return cls(x=np.nan, y=np.nan, z=np.nan, frame=frame)
|
|
465
|
+
|
|
466
|
+
def __neg__(self) -> Vector:
|
|
467
|
+
"""Negate the vector, inverting its direction.
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
Vector with inverted x, y, z components in the same frame
|
|
471
|
+
"""
|
|
472
|
+
return Vector(-self.x, -self.y, -self.z, frame=self.frame)
|
|
473
|
+
|
|
474
|
+
def cross(self, other: Vector) -> Vector:
|
|
475
|
+
"""Compute cross product with another vector.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
other: Vector to cross with
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
Vector perpendicular to both input vectors
|
|
482
|
+
|
|
483
|
+
Raises:
|
|
484
|
+
RuntimeError: If frames don't match
|
|
485
|
+
"""
|
|
486
|
+
check_same_frame(self, other)
|
|
487
|
+
x, y, z = np.cross(np.array(self), np.array(other))
|
|
488
|
+
return Vector(x, y, z, frame=self.frame)
|
|
489
|
+
|
|
490
|
+
def dot(self, other: Vector) -> float:
|
|
491
|
+
"""Compute dot product with another vector.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
other: Vector to dot with
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
scalar dot product between both vectors
|
|
498
|
+
|
|
499
|
+
Raises:
|
|
500
|
+
RuntimeError: If frames don't match
|
|
501
|
+
"""
|
|
502
|
+
check_same_frame(self, other)
|
|
503
|
+
return np.dot(np.array(self), np.array(other))
|
|
504
|
+
|
|
505
|
+
@classmethod
|
|
506
|
+
def from_array(cls, array: ArrayLike, frame: Frame) -> Self:
|
|
507
|
+
array = np.asarray(array).flatten()
|
|
508
|
+
if array.shape != (3,):
|
|
509
|
+
raise ValueError(f"Expected 3 coordinates, got {array.shape}")
|
|
510
|
+
return cls(x=array[0], y=array[1], z=array[2], w=0.0, frame=frame)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
class Point(GeometricPrimitive):
|
|
514
|
+
"""Geometric point representing position in space.
|
|
515
|
+
|
|
516
|
+
Points have homogeneous coordinate w=1, making them affected by translation.
|
|
517
|
+
|
|
518
|
+
Arithmetic semantics:
|
|
519
|
+
- Point - Point = Vector (displacement between positions)
|
|
520
|
+
- Point + Vector = Point (displace position)
|
|
521
|
+
- Point - Vector = Point (displace position backwards)
|
|
522
|
+
- Point + Point = ERROR (undefined operation)
|
|
523
|
+
"""
|
|
524
|
+
|
|
525
|
+
def __init__(self, x: float, y: float, z: float, frame: Frame, *, w=1.0):
|
|
526
|
+
"""Initialize point in given frame.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
x: X coordinate
|
|
530
|
+
y: Y coordinate
|
|
531
|
+
z: Z coordinate
|
|
532
|
+
frame: Reference frame
|
|
533
|
+
w: Homogeneous coordinate (should be 1 for points)
|
|
534
|
+
"""
|
|
535
|
+
super().__init__(x=x, y=y, z=z, w=w, frame=frame)
|
|
536
|
+
|
|
537
|
+
@overload
|
|
538
|
+
def __sub__(self, other: Point) -> Vector: ...
|
|
539
|
+
|
|
540
|
+
@overload
|
|
541
|
+
def __sub__(self, other: Vector) -> Point: ...
|
|
542
|
+
|
|
543
|
+
@overload
|
|
544
|
+
def __sub__(self, other: NDArray[np.floating]) -> NDArray[np.floating]: ...
|
|
545
|
+
|
|
546
|
+
def __sub__(
|
|
547
|
+
self, other: Point | Vector | NDArray[np.floating]
|
|
548
|
+
) -> Point | Vector | NDArray[np.floating]:
|
|
549
|
+
"""Subtract point or vector from this point.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
other: Point, Vector, or numpy array
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
Vector if subtracting Point, Point if subtracting Vector
|
|
556
|
+
|
|
557
|
+
Raises:
|
|
558
|
+
RuntimeError: If frames don't match
|
|
559
|
+
"""
|
|
560
|
+
if isinstance(other, Point):
|
|
561
|
+
check_same_frame(self, other)
|
|
562
|
+
x, y, z = np.array(self) - np.array(other)
|
|
563
|
+
return Vector(x, y, z, frame=self.frame)
|
|
564
|
+
elif isinstance(other, Vector):
|
|
565
|
+
check_same_frame(self, other)
|
|
566
|
+
x, y, z = np.array(self) - np.array(other)
|
|
567
|
+
return Point(x, y, z, frame=self.frame)
|
|
568
|
+
else:
|
|
569
|
+
return other.__rsub__(self)
|
|
570
|
+
|
|
571
|
+
@overload
|
|
572
|
+
def __add__(self, other: Vector) -> Point: ...
|
|
573
|
+
|
|
574
|
+
@overload
|
|
575
|
+
def __add__(self, other: NDArray[np.floating]) -> NDArray[np.floating]: ...
|
|
576
|
+
|
|
577
|
+
def __add__(
|
|
578
|
+
self, other: Vector | NDArray[np.floating]
|
|
579
|
+
) -> Point | NDArray[np.floating]:
|
|
580
|
+
"""Add vector to this point.
|
|
581
|
+
|
|
582
|
+
Args:
|
|
583
|
+
other: Vector or numpy array
|
|
584
|
+
|
|
585
|
+
Returns:
|
|
586
|
+
Resulting point
|
|
587
|
+
|
|
588
|
+
Raises:
|
|
589
|
+
TypeError: If attempting to add two Points
|
|
590
|
+
RuntimeError: If frames don't match
|
|
591
|
+
"""
|
|
592
|
+
if isinstance(other, Point):
|
|
593
|
+
raise TypeError("Can not add 2 Points.")
|
|
594
|
+
elif isinstance(other, Vector):
|
|
595
|
+
check_same_frame(self, other)
|
|
596
|
+
x, y, z = np.array(self) + np.array(other)
|
|
597
|
+
return Point(x, y, z, frame=self.frame)
|
|
598
|
+
else:
|
|
599
|
+
return other.__add__(self)
|
|
600
|
+
|
|
601
|
+
def __mul__(self, other):
|
|
602
|
+
raise TypeError(
|
|
603
|
+
f"Scalar multiplication of {self.__class__.__qualname__} is undefined. "
|
|
604
|
+
"Points can only be scaled relative to an origin. Use:\n"
|
|
605
|
+
" (point - origin) * scalar + origin # Scale relative to origin\n"
|
|
606
|
+
"Or convert to array:\n"
|
|
607
|
+
" np.array(point) * scalar # Coordinate manipulation"
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
def __truediv__(self, other):
|
|
611
|
+
raise TypeError(
|
|
612
|
+
f"Scalar division of {self.__class__.__qualname__} is undefined. "
|
|
613
|
+
"Points can only be scaled relative to an origin. Use:\n"
|
|
614
|
+
" (point - origin) * scalar + origin # Scale relative to origin\n"
|
|
615
|
+
"Or convert to array:\n"
|
|
616
|
+
" np.array(point) * scalar # Coordinate manipulation"
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
@classmethod
|
|
620
|
+
def create_origin(cls, frame: Frame) -> Point:
|
|
621
|
+
"""Return a point at the origin of the specified coordinate system"""
|
|
622
|
+
return cls(x=0.0, y=0.0, z=0.0, frame=frame)
|
|
623
|
+
|
|
624
|
+
@classmethod
|
|
625
|
+
def create_nan(cls, frame: Frame) -> Point:
|
|
626
|
+
"""Return a point at the origin of the specified coordinate system"""
|
|
627
|
+
return cls(x=np.nan, y=np.nan, z=np.nan, frame=frame)
|
|
628
|
+
|
|
629
|
+
@classmethod
|
|
630
|
+
def from_array(cls, array: ArrayLike, frame: Frame) -> Self:
|
|
631
|
+
array = np.asarray(array).flatten()
|
|
632
|
+
if array.shape != (3,):
|
|
633
|
+
raise ValueError(f"Expected 3 coordinates, got {array.shape}")
|
|
634
|
+
return cls(x=array[0], y=array[1], z=array[2], w=1.0, frame=frame)
|
|
635
|
+
|
|
636
|
+
@classmethod
|
|
637
|
+
def list_from_array(cls, points: ArrayLike, frame) -> list[Point]:
|
|
638
|
+
"""Creates a list of Point instances from an array of points."""
|
|
639
|
+
points = np.asarray(points).reshape((-1, 3))
|
|
640
|
+
return [cls.from_array(arr, frame=frame) for arr in points]
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
@implements(np.copy)
|
|
644
|
+
def _copy_geometric(
|
|
645
|
+
a: GeometricPrimitive,
|
|
646
|
+
order: str = "K", # Ignored for geometric primitives
|
|
647
|
+
subok: bool = False, # Ignored for geometric primitives
|
|
648
|
+
) -> GeometricPrimitive:
|
|
649
|
+
return a.copy()
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
@implements(np.asarray)
|
|
653
|
+
def _asarray_geometric(
|
|
654
|
+
a: GeometricPrimitive, dtype=None, order=None, *, like=None
|
|
655
|
+
) -> np.ndarray:
|
|
656
|
+
"""Convert geometric primitive to array (returns coordinates)."""
|
|
657
|
+
if dtype is not None and not np.issubdtype(dtype, np.floating):
|
|
658
|
+
raise TypeError("Geometric primitives require floating-point dtype")
|
|
659
|
+
return a.__array__(dtype=dtype)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
@implements(np.round)
|
|
663
|
+
def _round_geometric[T: GeometricPrimitive](
|
|
664
|
+
a: T, decimals: int = 0, out: None = None
|
|
665
|
+
) -> T:
|
|
666
|
+
if out is not None:
|
|
667
|
+
raise TypeError(
|
|
668
|
+
"out parameter not supported for GeometricPrimitive. "
|
|
669
|
+
"Use .round_() for in-place modification."
|
|
670
|
+
)
|
|
671
|
+
return a.round(decimals=decimals)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
@implements(np.clip)
|
|
675
|
+
def _clip_geometric[T: GeometricPrimitive](
|
|
676
|
+
a: T,
|
|
677
|
+
a_min: float = -np.inf,
|
|
678
|
+
a_max: float = np.inf,
|
|
679
|
+
out: None = None,
|
|
680
|
+
) -> T:
|
|
681
|
+
if out is not None:
|
|
682
|
+
raise TypeError(
|
|
683
|
+
"out parameter not supported for GeometricPrimitive. "
|
|
684
|
+
"Use .clip_() for in-place modification."
|
|
685
|
+
)
|
|
686
|
+
return a.clip(a_max=a_max, a_min=a_min)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
@implements(np.floor)
|
|
690
|
+
def _floor_geometric[T: GeometricPrimitive](a: T, out=None) -> T:
|
|
691
|
+
return a.floor()
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
@implements(np.ceil)
|
|
695
|
+
def _ceil_geometric[T: GeometricPrimitive](a: T, out=None) -> T:
|
|
696
|
+
return a.ceil()
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
@implements(np.trunc)
|
|
700
|
+
def _trunc_geometric[T: GeometricPrimitive](a: T, out=None) -> T:
|
|
701
|
+
return a.trunc()
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
@implements(np.rint)
|
|
705
|
+
def _rint_geometric[T: GeometricPrimitive](a: T, out=None) -> T:
|
|
706
|
+
return a.rint()
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
@implements(np.fix)
|
|
710
|
+
def _fix_geometric[T: GeometricPrimitive](a: T, out=None) -> T:
|
|
711
|
+
return a.fix()
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
@implements(np.isnan)
|
|
715
|
+
def _isnan_geometric(a: GeometricPrimitive) -> NDArray[np.bool_]:
|
|
716
|
+
return np.isnan(a._homogeneous[:3])
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
@implements(np.isinf)
|
|
720
|
+
def _isinf_geometric(a: GeometricPrimitive) -> NDArray[np.bool_]:
|
|
721
|
+
return np.isinf(a._homogeneous[:3])
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
@implements(np.isfinite)
|
|
725
|
+
def _isfinite_geometric(a: GeometricPrimitive) -> NDArray[np.bool_]:
|
|
726
|
+
return np.isfinite(a._homogeneous[:3])
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
@implements(np.absolute)
|
|
730
|
+
def _absolute_geometric(a: GeometricPrimitive) -> float:
|
|
731
|
+
"""Absolute value: magnitude for Vector, error for Point."""
|
|
732
|
+
if isinstance(a, Vector):
|
|
733
|
+
return a.magnitude
|
|
734
|
+
raise TypeError(
|
|
735
|
+
f"abs({a.__class__.__qualname__}) is geometrically undefined. "
|
|
736
|
+
"Use np.abs(np.array(point)) for coordinate-wise absolute values."
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
@implements(np.cross)
|
|
741
|
+
def _cross_geometric(a: Vector, b: Vector, **kwargs) -> Vector:
|
|
742
|
+
"""Cross product for Vectors (frame-aware).
|
|
743
|
+
|
|
744
|
+
Args:
|
|
745
|
+
a: First vector (must be Vector for frame-aware operation)
|
|
746
|
+
b: Second vector (must be Vector and same frame as a)
|
|
747
|
+
**kwargs: Additional arguments passed to np.cross on arrays
|
|
748
|
+
|
|
749
|
+
Returns:
|
|
750
|
+
Vector perpendicular to both inputs (if both are Vectors)
|
|
751
|
+
ndarray otherwise
|
|
752
|
+
|
|
753
|
+
Raises:
|
|
754
|
+
TypeError: If a is not a Vector
|
|
755
|
+
RuntimeError: If frames don't match
|
|
756
|
+
"""
|
|
757
|
+
if not isinstance(a, Vector) or not isinstance(b, Vector):
|
|
758
|
+
raise TypeError(
|
|
759
|
+
f"np.cross requires both Vector arguments, "
|
|
760
|
+
f"got {type(a).__name__} and {type(b).__name__}"
|
|
761
|
+
)
|
|
762
|
+
return a.cross(b)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
@implements(np.dot)
|
|
766
|
+
def _dot_geometric(a: Vector, b: Vector, out=None) -> float:
|
|
767
|
+
"""Dot product for Vectors (frame-aware).
|
|
768
|
+
|
|
769
|
+
Args:
|
|
770
|
+
a: First vector (must be Vector for frame-aware operation)
|
|
771
|
+
b: Second vector (must be Vector and same frame as a)
|
|
772
|
+
out: Output array (not supported for GeometricPrimitives)
|
|
773
|
+
|
|
774
|
+
Returns:
|
|
775
|
+
Scalar dot product (if both are Vectors)
|
|
776
|
+
ndarray otherwise
|
|
777
|
+
|
|
778
|
+
Raises:
|
|
779
|
+
TypeError: If a is not a Vector or out is specified
|
|
780
|
+
RuntimeError: If frames don't match
|
|
781
|
+
"""
|
|
782
|
+
if out is not None:
|
|
783
|
+
raise TypeError(
|
|
784
|
+
"out parameter not supported for GeometricPrimitive dot product"
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
if not isinstance(a, Vector) or not isinstance(b, Vector):
|
|
788
|
+
raise TypeError(
|
|
789
|
+
f"np.dot requires both Vector arguments, "
|
|
790
|
+
f"got {type(a).__name__} and {type(b).__name__}"
|
|
791
|
+
)
|
|
792
|
+
return a.dot(b)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
@implements(np.linalg.norm)
|
|
796
|
+
def _norm_geometric(
|
|
797
|
+
x: GeometricPrimitive,
|
|
798
|
+
ord=None,
|
|
799
|
+
axis=None,
|
|
800
|
+
keepdims=False,
|
|
801
|
+
) -> float:
|
|
802
|
+
"""Compute norm for geometric primitives.
|
|
803
|
+
|
|
804
|
+
Args:
|
|
805
|
+
x: Vector to compute norm of
|
|
806
|
+
ord: Order of the norm (only default supported)
|
|
807
|
+
axis: Axis parameter (not supported for GeometricPrimitives)
|
|
808
|
+
keepdims: Keep dimensions parameter (not supported for GeometricPrimitives)
|
|
809
|
+
|
|
810
|
+
Returns:
|
|
811
|
+
Magnitude of the vector
|
|
812
|
+
|
|
813
|
+
Raises:
|
|
814
|
+
TypeError: If x is not a Vector or unsupported parameters are used
|
|
815
|
+
"""
|
|
816
|
+
if not isinstance(x, Vector):
|
|
817
|
+
raise TypeError(
|
|
818
|
+
f"np.linalg.norm for {x.__class__.__qualname__} is undefined. "
|
|
819
|
+
"Use np.linalg.norm(np.array(obj)) for coordinate-wise norm."
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
if ord is not None:
|
|
823
|
+
raise TypeError(
|
|
824
|
+
"ord parameter not supported for Vector. "
|
|
825
|
+
"Use np.linalg.norm(np.array(vector), ord=...) for custom norms."
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
if axis is not None:
|
|
829
|
+
raise TypeError("axis parameter not supported for Vector.")
|
|
830
|
+
|
|
831
|
+
if keepdims:
|
|
832
|
+
raise TypeError("keepdims parameter not supported for Vector.")
|
|
833
|
+
|
|
834
|
+
return x.magnitude
|