import uuid
import math
from .tolerance import Tolerance, TO_DEGREES, TO_RADIANS
[docs]
class Vector:
"""A 3D vector with visual properties.
Parameters
----------
x : float, optional
X coordinate. Defaults to 0.0.
y : float, optional
Y coordinate. Defaults to 0.0.
z : float, optional
Z coordinate. Defaults to 0.0.
Attributes
----------
guid : str
The unique identifier of the vector.
name : str
The name of the vector.
x : float
The X coordinate of the vector.
y : float
The Y coordinate of the vector.
z : float
The Z coordinate of the vector.
"""
[docs]
def __init__(self, x=0.0, y=0.0, z=0.0):
self.guid = str(uuid.uuid4())
self.name = "my_vector"
self._x = x
self._y = y
self._z = z
self._length = 0.0
self._has_length = False
@property
def x(self):
"""Get the X coordinate."""
return self._x
@x.setter
def x(self, value):
"""Set the X coordinate and invalidate length cache."""
self._x = value
self._has_length = False
@property
def y(self):
"""Get the Y coordinate."""
return self._y
@y.setter
def y(self, value):
"""Set the Y coordinate and invalidate length cache."""
self._y = value
self._has_length = False
@property
def z(self):
"""Get the Z coordinate."""
return self._z
@z.setter
def z(self, value):
"""Set the Z coordinate and invalidate length cache."""
self._z = value
self._has_length = False
def __str__(self):
return f"Vector({self.x}, {self.y}, {self.z})"
def __repr__(self):
return f"Vector({self.guid}, {self.name}, {self.x}, {self.y}, {self.z})"
def __eq__(self, other):
return (
self.name == other.name
and round(self.x, 6) == round(other.x, 6)
and round(self.y, 6) == round(other.y, 6)
and round(self.z, 6) == round(other.z, 6)
)
def __ne__(self, other):
return not self == other
###########################################################################################
# No-copy Operators
###########################################################################################
def __getitem__(self, index):
if index == 0:
return self.x
elif index == 1:
return self.y
elif index == 2:
return self.z
else:
raise IndexError("Index out of range")
def __setitem__(self, index, value):
if index == 0:
self.x = value
elif index == 1:
self.y = value
elif index == 2:
self.z = value
else:
raise IndexError("Index out of range")
self._has_length = False
def __imul__(self, other):
self._x *= other
self._y *= other
self._z *= other
self._has_length = False
return self
def __itruediv__(self, other):
self._x /= other
self._y /= other
self._z /= other
self._has_length = False
return self
def __iadd__(self, other):
self._x += other._x
self._y += other._y
self._z += other._z
self._has_length = False
return self
def __isub__(self, other):
self._x -= other._x
self._y -= other._y
self._z -= other._z
self._has_length = False
return self
###########################################################################################
# Copy Operators
###########################################################################################
def __mul__(self, other):
return Vector(self._x * other, self._y * other, self._z * other)
def __truediv__(self, other):
return Vector(self._x / other, self._y / other, self._z / other)
def __add__(self, other):
return Vector(self._x + other._x, self._y + other._y, self._z + other._z)
def __sub__(self, other):
return Vector(self._x - other._x, self._y - other._y, self._z - other._z)
###########################################################################################
# Static Methods
###########################################################################################
[docs]
@staticmethod
def x_axis():
"""Get unit vector along the x-axis.
Returns
-------
:class:`Vector`
Unit vector (1, 0, 0).
"""
return Vector(1.0, 0.0, 0.0)
[docs]
@staticmethod
def y_axis():
"""Get unit vector along the y-axis.
Returns
-------
:class:`Vector`
Unit vector (0, 1, 0).
"""
return Vector(0.0, 1.0, 0.0)
[docs]
@staticmethod
def z_axis():
"""Get unit vector along the z-axis.
Returns
-------
:class:`Vector`
Unit vector (0, 0, 1).
"""
return Vector(0.0, 0.0, 1.0)
[docs]
@staticmethod
def from_start_and_end(start, end):
"""Vector from start to end (end - start).
Parameters
----------
start : :class:`Vector`
Start vector.
end : :class:`Vector`
End vector.
Returns
-------
:class:`Vector`
The vector from start to end.
"""
return Vector(end.x - start.x, end.y - start.y, end.z - start.z)
###########################################################################################
# Details
###########################################################################################
[docs]
def reverse(self):
"""Reverse the vector (negate all components).
Returns
-------
:class:`Vector`
Self.
"""
self._x = -self._x
self._y = -self._y
self._z = -self._z
self._has_length = False
return self
[docs]
def compute_length(self):
"""Compute the length of the vector using optimized algorithm.
Returns
-------
float
The length of the vector.
"""
length = 0.0
x = abs(self._x)
y = abs(self._y)
z = abs(self._z)
# Handle two zero case:
x_zero = x < Tolerance.ZERO_TOLERANCE
y_zero = y < Tolerance.ZERO_TOLERANCE
z_zero = z < Tolerance.ZERO_TOLERANCE
if x_zero and y_zero and z_zero:
length = 0.0
return length
elif x_zero and y_zero:
length = z
return length
elif x_zero and z_zero:
length = y
return length
elif y_zero and z_zero:
length = x
return length
# Handle one or none zero case:
# Sort so that x is the largest component
if y >= x and y >= z:
length = x
x = y
y = length
elif z >= x and z >= y:
length = x
x = z
z = length
# For small denormalized doubles (positive but smaller
# than DOUBLE_MIN), some compilers/FPUs set 1.0/x to +INF.
# Without the DOUBLE_MIN test we end up with
# microscopic vectors that have infinite length!
if x > 2.22507385850720200e-308:
y /= x
z /= x
length = x * math.sqrt(1.0 + y * y + z * z)
elif x > 0.0 and math.isfinite(x):
length = x
else:
length = 0.0
return length
[docs]
def magnitude(self):
"""Get the cached magnitude of the vector, computing it if necessary.
Returns
-------
float
The magnitude (length) of the vector.
"""
if not self._has_length:
self._length = self.compute_length()
self._has_length = True
return self._length
[docs]
def length_squared(self):
"""Get the squared length of the vector (avoids sqrt for performance).
Returns
-------
float
The squared length of the vector.
"""
return self._x * self._x + self._y * self._y + self._z * self._z
[docs]
def normalize_self(self):
"""Normalize the vector in place (make it unit length).
Returns
-------
bool
True if successful, False if vector has zero length.
"""
d = self.magnitude()
if d > 0.0:
self._x /= d
self._y /= d
self._z /= d
self._length = 1.0
self._has_length = True
return True
return False
[docs]
def normalize(self):
"""Return a normalized copy of the vector.
Returns
-------
Vector
A new vector that is the unit vector of this vector.
"""
normalized_vector = Vector(self._x, self._y, self._z)
normalized_vector.normalize_self()
return normalized_vector
[docs]
def dot(self, other):
"""Calculate dot product with another vector.
Parameters
----------
other : :class:`Vector`
Other vector.
Returns
-------
float
Dot product value.
"""
return self._x * other._x + self._y * other._y + self._z * other._z
[docs]
def cross(self, other):
"""Calculate cross product with another vector.
Parameters
----------
other : :class:`Vector`
Other vector.
Returns
-------
:class:`Vector`
Cross product vector (orthogonal to inputs).
"""
x = self._y * other._z - self._z * other._y
y = self._z * other._x - self._x * other._z
z = self._x * other._y - self._y * other._x
return Vector(x, y, z)
[docs]
def is_parallel_to(self, v):
"""Check if this vector is parallel/antiparallel to another.
Parameters
----------
v : :class:`Vector`
Other vector.
Returns
-------
int
1 if parallel, -1 if antiparallel, 0 otherwise.
"""
ll = self.magnitude() * v.magnitude()
if ll > 0.0:
cos_angle = self.dot(v) / ll
angle_in_radians = Tolerance.ANGLE_TOLERANCE_DEGREES * TO_RADIANS
cos_tol = math.cos(angle_in_radians)
if cos_angle >= cos_tol:
return 1 # Parallel
elif cos_angle <= -cos_tol:
return -1 # Antiparallel
else:
return 0 # Not parallel
else:
return 0 # Not parallel
[docs]
def angle(self, other, sign_by_cross_product=False, degrees=True, tolerance=1e-12):
"""Angle between this vector and another.
Parameters
----------
other : :class:`Vector`
The other vector.
sign_by_cross_product : bool, optional
If True, sign the angle using the z-component of the cross product.
degrees : bool, optional
If True (default), return angle in degrees; otherwise radians.
tolerance : float, optional
Denominator tolerance to treat near-zero lengths as zero.
Returns
-------
float
The angle value (degrees if `degrees` else radians).
"""
dot_product = self.dot(other)
len0 = self.magnitude()
len1 = other.magnitude()
denominator = len0 * len1
if denominator < tolerance:
return 0.0
cos_angle = dot_product / denominator
cos_angle = max(-1.0, min(1.0, cos_angle)) # Clamp to [-1, 1]
angle = math.acos(cos_angle)
if sign_by_cross_product:
# Raw cross product z-component for sign check
cross_z = self._x * other._y - self._y * other._x
if cross_z < 0:
angle = -angle
if degrees:
return math.degrees(angle)
return angle
[docs]
def projection(self, projection_vector, tolerance=1e-12):
"""Project this vector onto another vector.
Parameters
----------
projection_vector : :class:`Vector`
Vector to project onto.
tolerance : float, optional
Treat `projection_vector` length below this as zero.
Returns
-------
tuple
(projection_vector, projected_length, perpendicular_vector, perpendicular_length),
where projection_vector is :class:`Vector`, projected_length is float,
perpendicular_vector is :class:`Vector`, and perpendicular_length is float.
"""
projection_vector_length = projection_vector.magnitude()
if projection_vector_length < tolerance:
return Vector(0, 0, 0), 0.0, Vector(0, 0, 0), 0.0
projection_vector_unit = Vector(
projection_vector.x / projection_vector_length,
projection_vector.y / projection_vector_length,
projection_vector.z / projection_vector_length,
)
projected_vector_length = self.dot(projection_vector_unit)
out_projection_vector = projection_vector_unit * projected_vector_length
out_perpendicular_vector = self - out_projection_vector
out_perpendicular_length = out_perpendicular_vector.magnitude()
return (
out_projection_vector,
projected_vector_length,
out_perpendicular_vector,
out_perpendicular_length,
)
[docs]
def get_leveled_vector(self, vertical_height):
"""Get a copy scaled by a vertical height along the Z-axis.
Parameters
----------
vertical_height : float
Target vertical height.
Returns
-------
:class:`Vector`
Scaled copy matching the C++ implementation.
"""
copy = Vector(self._x, self._y, self._z)
if copy.normalize_self():
reference_vector = Vector(0, 0, 1)
angle = copy.angle(
reference_vector, sign_by_cross_product=True, degrees=True
)
inclined_offset = vertical_height / math.cos(angle)
copy *= inclined_offset
return copy
[docs]
@staticmethod
def cosine_law(
triangle_edge_length_a,
triangle_edge_length_b,
angle_in_between_edges,
degrees=True,
):
"""Calculate third side of triangle using the cosine law.
Parameters
----------
triangle_edge_length_a : float
Length of side a.
triangle_edge_length_b : float
Length of side b.
angle_in_between_edges : float
Angle between a and b.
degrees : bool, optional
If True, the angle is provided in degrees.
Returns
-------
float
Length of the third side.
"""
to_radians = TO_RADIANS if degrees else 1.0
return math.sqrt(
triangle_edge_length_a**2
+ triangle_edge_length_b**2
- 2
* triangle_edge_length_a
* triangle_edge_length_b
* math.cos(angle_in_between_edges * to_radians)
)
[docs]
@staticmethod
def sine_law_angle(
triangle_edge_length_a,
angle_in_front_of_a,
triangle_edge_length_b,
degrees=True,
):
"""Calculate angle using the sine law.
Parameters
----------
triangle_edge_length_a : float
Length of side a.
angle_in_front_of_a : float
Angle opposite to side a.
triangle_edge_length_b : float
Length of side b.
degrees : bool, optional
If True, return angle in degrees.
Returns
-------
float
Angle opposite to side b (degrees if `degrees`).
"""
to_radians = TO_RADIANS if degrees else 1.0
to_degrees = TO_DEGREES if degrees else 1.0
return (
math.asin(
(triangle_edge_length_b * math.sin(angle_in_front_of_a * to_radians))
/ triangle_edge_length_a
)
* to_degrees
)
[docs]
@staticmethod
def sine_law_length(
triangle_edge_length_a, angle_in_front_of_a, angle_in_front_of_b, degrees=True
):
"""Calculate side length using the sine law.
Parameters
----------
triangle_edge_length_a : float
Length of side a.
angle_in_front_of_a : float
Angle opposite to side a.
angle_in_front_of_b : float
Angle opposite to side b.
degrees : bool, optional
If True, angles are provided in degrees.
Returns
-------
float
Length of side b.
"""
to_radians = TO_RADIANS if degrees else 1.0
return (
triangle_edge_length_a * math.sin(angle_in_front_of_b * to_radians)
) / math.sin(angle_in_front_of_a * to_radians)
[docs]
@staticmethod
def angle_between_vector_xy_components(vector, degrees=True):
"""Angle between the vector's XY components.
Parameters
----------
vector : :class:`Vector`
Input vector.
degrees : bool, optional
If True, return degrees; otherwise radians.
Returns
-------
float
Angle in the XY plane.
"""
to_degrees = TO_DEGREES if degrees else 1.0
return math.atan(vector.y / vector.x) * to_degrees
[docs]
@staticmethod
def sum_of_vectors(vectors):
"""Sum a list of vectors (component-wise).
Parameters
----------
vectors : list[:class:`Vector`]
Vectors to sum.
Returns
-------
:class:`Vector`
The component-wise sum.
"""
x = y = z = 0.0
for vector in vectors:
x += vector._x
y += vector._y
z += vector._z
return Vector(x, y, z)
[docs]
def coordinate_direction_3angles(self, degrees=True):
"""Compute coordinate direction angles (alpha, beta, gamma).
Parameters
----------
degrees : bool, optional
Return angles in degrees if True, radians if False.
Returns
-------
tuple
(alpha, beta, gamma)
"""
r = math.sqrt(self._x**2 + self._y**2 + self._z**2)
if r == 0:
return (0, 0, 0)
x_proportion = self._x / r
y_proportion = self._y / r
z_proportion = self._z / r
alpha = math.acos(x_proportion)
beta = math.acos(y_proportion)
gamma = math.acos(z_proportion)
if degrees:
alpha *= TO_DEGREES
beta *= TO_DEGREES
gamma *= TO_DEGREES
return (alpha, beta, gamma)
[docs]
def coordinate_direction_2angles(self, degrees=True):
"""Compute coordinate direction angles (phi, theta).
Parameters
----------
degrees : bool, optional
Return angles in degrees if True, radians if False.
Returns
-------
tuple
(phi, theta)
"""
r = math.sqrt(self._x**2 + self._y**2 + self._z**2)
if r == 0:
return (0, 0)
phi = math.acos(self._z / r)
theta = math.atan2(self._y, self._x)
if degrees:
phi *= TO_DEGREES
theta *= TO_DEGREES
return (phi, theta)
[docs]
def perpendicular_to(self, v):
"""Set this vector to be perpendicular to `v`.
Parameters
----------
v : :class:`Vector`
Reference vector.
Returns
-------
bool
True on success, False otherwise.
"""
k = 2
if abs(v.y) > abs(v.x):
if abs(v.z) > abs(v.y):
# |v.z| > |v.y| > |v.x|
i, j, k = 2, 1, 0
a, b = v.z, -v.y
elif abs(v.z) >= abs(v.x):
# |v.y| >= |v.z| >= |v.x|
i, j, k = 1, 2, 0
a, b = v.y, -v.z
else:
# |v.y| > |v.x| > |v.z|
i, j, k = 1, 0, 2
a, b = v.y, -v.x
elif abs(v.z) > abs(v.x):
# |v.z| > |v.x| >= |v.y|
i, j, k = 2, 0, 1
a, b = v.z, -v.x
elif abs(v.z) > abs(v.y):
# |v.x| >= |v.z| > |v.y|
i, j, k = 0, 2, 1
a, b = v.x, -v.z
else:
# |v.x| >= |v.y| >= |v.z|
i, j, k = 0, 1, 2
a, b = v.x, -v.y
coords = [0, 0, 0]
coords[i] = b
coords[j] = a
coords[k] = 0.0
self._x, self._y, self._z = coords
self._has_length = False
###########################################################################################
# Polymorphic JSON Serialization (COMPAS-style)
###########################################################################################
def __jsondump__(self):
"""Serialize to polymorphic JSON format with type field."""
return {
"type": f"{self.__class__.__name__}",
"guid": self.guid,
"name": self.name,
"x": self.x,
"y": self.y,
"z": self.z,
}
@classmethod
def __jsonload__(cls, data, guid=None, name=None):
"""Deserialize from polymorphic JSON format."""
vec = cls(data["x"], data["y"], data["z"])
vec.guid = guid
vec.name = name
return vec