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/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