import unittest
from math import sqrt
[docs]
def same_basis(e1, e2):
"""Return True if two elements share the same basis."""
if e1.basis is None and e2.basis is not None:
return e2.basis.is_standard_basis
elif e2.basis is None and e1.basis is not None:
return e1.basis.is_standard_basis
return e1.basis == e2.basis
[docs]
def is_standard_basis(basis):
"""Return True if the basis is None or is the standard (identity) basis."""
if basis is None:
return True
return basis.is_standard_basis
[docs]
def same_origin(e1, e2):
"""Return True if two elements share the same origin."""
return e1.origin == e2.origin
[docs]
class PointDefault:
"""Context manager to temporarily override the default basis for Points."""
def __init__(self, basis=None):
self.save_default_basis = Point.default_basis
Point.default_basis = basis
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
Point.default_basis = self.save_default_basis
[docs]
class Vector:
"""Two-dimensional vector with optional basis for coordinate transformations."""
def __init__(self, *args, basis=None):
self.components = args
self.basis = basis
@property
def c0(self):
"""Return the first component of the vector."""
return self.components[0]
@property
def c1(self):
"""Return the second component of the vector."""
return self.components[1]
def __repr__(self):
return str(self)
def __str__(self):
basis_str = ""
if self.basis is not None and not self.basis.is_standard_basis:
basis_str = f" basis=[{self.basis.e0}, {self.basis.e1}]"
return f"V({self.c0:.1f}, {self.c1:.1f}){basis_str}"
def __add__(self, rhs: "Vector"):
if same_basis(self, rhs):
return Vector(self.c0 + rhs.c0, self.c1 + rhs.c1, basis=self.basis)
else:
u = self.standard_coordinates()
v = rhs.standard_coordinates()
c0 = u.c0 + v.c0
c1 = u.c1 + v.c1
return Vector(c0, c1)
def __radd__(self, rhs):
return self.__add__(rhs)
def __sub__(self, rhs: "Vector"):
if same_basis(self, rhs):
return Vector(self.c0 - rhs.c0, self.c1 - rhs.c1, basis=self.basis)
else:
u = self.standard_coordinates()
v = rhs.standard_coordinates()
c0 = u.c0 - v.c0
c1 = u.c1 - v.c1
return Vector(c0, c1)
def __mul__(self, scalar):
return Vector(self.c0 * scalar, self.c1 * scalar, basis=self.basis)
def __rmul__(self, scalar):
return self.__mul__(scalar)
def __truediv__(self, scalar):
return Vector(self.c0 / scalar, self.c1 / scalar, basis=self.basis)
def __eq__(self, rhs: "Vector"):
assert isinstance(rhs, Vector)
return self.components == rhs.components and self.basis == rhs.basis
@property
def length(self):
"""Return the Euclidean length of the vector in standard coordinates."""
v = self.standard_coordinates()
return sqrt(v.c0 * v.c0 + v.c1 * v.c1)
@property
def is_unitary(self):
"""Return True if the vector has unit length within floating-point tolerance."""
return abs(self.length - 1.0) < 1e-6
[docs]
def is_perpendicular(self, rhs):
"""Return True if this vector is perpendicular to rhs."""
return abs(self.dot(rhs)) < 1e-6
[docs]
def dot(self, rhs: "Vector"):
"""Compute the dot product with another vector in the same basis."""
assert same_basis(self, rhs)
return self.c0 * rhs.c0 + self.c1 * rhs.c1
[docs]
def normalized(self):
"""Return a unit-length vector in the same direction and basis."""
inv_l = 1.0 / self.length
return Vector(self.c0 * inv_l, self.c1 * inv_l, basis=self.basis)
[docs]
def scaled(self, scale):
"""Return a new vector with each component multiplied by the corresponding scale factor."""
return Vector(self.c0 * scale[0], self.c1 * scale[1], basis=self.basis)
[docs]
def in_basis(self, new_basis):
"""Express this vector in a different basis by projection."""
v = self.standard_coordinates()
e0, e1 = (new_basis.e0, new_basis.e1)
c0 = v.dot(e0) / (e0.length * e0.length)
c1 = v.dot(e1) / (e1.length * e1.length)
return Vector(c0, c1, basis=new_basis)
[docs]
def change_basis(self, new_basis):
"""Re-express this vector in-place in the given basis."""
v = self.in_basis(new_basis)
self.components = (v.c0, v.c1)
self.basis = v.basis
return self
[docs]
def standard_tuple(self):
"""Return the components as a tuple in the standard basis."""
p = self.standard_coordinates()
return p.components
[docs]
def standard_coordinates(self):
"""Return an equivalent vector expressed in the standard basis."""
if self.basis is None:
return self
assert self.basis.e0.basis is None
assert same_basis(self.basis.e0, self.basis.e1)
return self.c0 * self.basis.e0 + self.c1 * self.basis.e1
x̂ = Vector(1, 0, basis=None)
ŷ = Vector(0, 1, basis=None)
[docs]
class Point:
"""Two-dimensional point with basis and optional reference point for coordinate transforms."""
default_basis = None
def __init__(self, *args, basis: "Basis" = None, reference_point: "Point" = None):
assert isinstance(args, tuple)
assert len(args) == 2
self.components = args
if basis is None:
if Point.default_basis is not None:
basis = Basis(Point.default_basis.e0, Point.default_basis.e1)
else:
basis = Basis()
self.basis = basis
self.reference_point = reference_point
@property
def x(self):
"""Return the x (first) coordinate."""
return self.c0
@property
def y(self):
"""Return the y (second) coordinate."""
return self.c1
@property
def c0(self):
"""Return the first component of the point."""
return self.components[0]
@property
def c1(self):
"""Return the second component of the point."""
return self.components[1]
[docs]
def is_same_as(self, rhs):
"""Return True if this point is equal to rhs."""
assert isinstance(rhs, Point)
return self.__eq__(rhs)
def __eq__(self, rhs: "Point"):
assert isinstance(rhs, Point)
c0_equality = abs(self.components[0] - rhs.components[0]) < 1e-6
c1_equality = abs(self.components[1] - rhs.components[1]) < 1e-6
return c0_equality and c1_equality and self.basis == rhs.basis
def __repr__(self):
return str(self)
def __str__(self):
basis_str = ""
ref_str = ""
if not self.basis.is_standard_basis:
basis_str = f" basis=[{self.basis.e0}, {self.basis.e1}]"
if self.reference_point is not None:
ref_str = f" ref=P({self.reference_point.x}, {self.reference_point.y})"
return f"P({self.x:.1f}, {self.y:.1f}){basis_str}{ref_str}"
def __sub__(self, rhs: "Point"):
# assert isinstance(rhs, Point)
# assert same_basis(self, rhs)
if same_basis(self, rhs):
c0 = self.c0 - rhs.c0
c1 = self.c1 - rhs.c1
return Vector(c0, c1, basis=self.basis)
else:
u = self.standard_coordinates()
v = rhs.standard_coordinates()
c0 = u.c0 - v.c0
c1 = u.c1 - v.c1
return Vector(c0, c1)
def __add__(self, rhs: "Vector"):
# assert isinstance(rhs, Vector)
if same_basis(self, rhs):
return Point(
self.c0 + rhs.c0,
self.c1 + rhs.c1,
basis=self.basis,
reference_point=self.reference_point,
)
else:
u = self.standard_coordinates()
v = rhs.standard_coordinates()
return Point(
u.c0 + v.c0,
u.c1 + v.c1,
basis=u.basis,
reference_point=self.reference_point,
)
[docs]
def standard_tuple(self):
"""Return the components as a tuple in the standard basis."""
p = self.standard_coordinates()
return p.components
[docs]
def standard_coordinates(self):
"""Return an equivalent point expressed in the standard basis."""
if self.basis.is_standard_basis and self.reference_point is None:
return self
assert is_standard_basis(
self.basis.e0.basis
) # For now, hopefully anything later
assert is_standard_basis(self.basis.e1.basis)
reference_point = self.reference_point
if reference_point is None:
reference_point = Point(0, 0)
reference_point = reference_point.standard_coordinates()
e0 = self.basis.e0.standard_coordinates()
e1 = self.basis.e1.standard_coordinates()
v = reference_point + self.c0 * e0 + self.c1 * e1
return Point(*v.components)
[docs]
def in_reference_frame(self, basis, new_reference_point):
"""Express this point in a new reference frame defined by basis and origin."""
pt = self.standard_coordinates()
ref_point = new_reference_point.standard_coordinates()
vector_position = pt - ref_point
position_vector = vector_position.in_basis(basis)
return Point(
*position_vector.components,
basis=basis,
reference_point=new_reference_point,
)
[docs]
class Basis:
"""Orthogonal 2D basis defined by two vectors, defaulting to the standard basis."""
defined = {}
def __init__(self, e0: Vector = None, e1: Vector = None):
"""Initialize with two basis vectors, defaulting to the standard basis."""
if e0 is None:
e0 = x̂
if e1 is None:
e1 = ŷ
assert e0.basis is None
assert e1.basis is None
assert e0.is_perpendicular(e1)
assert e0.length > 0
assert e1.length > 0
self._e0 = e0
self._e1 = e1
@property
def e0(self):
"""Return the first basis vector."""
return self._e0
@property
def e1(self):
"""Return the second basis vector."""
return self._e1
def __eq__(self, rhs: "Basis"):
assert isinstance(rhs, Basis)
e0_c0_equality = abs(self.e0.components[0] - rhs.e0.components[0]) < 1e-6
e0_c1_equality = abs(self.e0.components[1] - rhs.e0.components[1]) < 1e-6
e1_c0_equality = abs(self.e1.components[0] - rhs.e1.components[0]) < 1e-6
e1_c1_equality = abs(self.e1.components[1] - rhs.e1.components[1]) < 1e-6
return e0_c0_equality and e0_c1_equality and e1_c0_equality and e1_c1_equality
def __repr__(self):
return str(self)
def __str__(self):
return f"[{self.e0}, {self.e1}]"
@property
def is_standard_basis(self):
"""Return True if this is the standard Cartesian basis."""
return self.e0.is_unitary and self.e0.c1 == 0 and self.e1.is_unitary and self.e1.c0 == 0
@property
def is_orthogonal(self):
"""Return True if the two basis vectors are perpendicular."""
return self.e0.is_perpendicular(self.e1)
@property
def is_orthonormal(self):
"""Return True if the basis is orthogonal and both vectors are unitary."""
if self.is_orthonormal:
return self.e0.is_unitary and self.e1.is_unitary
[docs]
class DynamicBasis(Basis):
"""Basis that reads its vectors dynamically from an attribute on a source object."""
def __init__(self, source, basis_name):
self.source = source
self.basis_name = basis_name
@property
def e0(self):
"""Return the first basis vector resolved from the source at access time."""
basis = getattr(self.source, self.basis_name)
if basis is None:
raise ValueError('Basis {self.basis_name} from {self.source} not found')
return basis.e0
@property
def e1(self):
"""Return the second basis vector resolved from the source at access time."""
basis = getattr(self.source, self.basis_name)
if basis is None:
raise ValueError('Basis {self.basis_name} from {self.source} not found')
return basis.e1
[docs]
class Doublet(tuple):
"""Immutable two-element tuple with arithmetic operations for 2D math."""
def __new__(cls, *args):
"""Create a new Doublet from two values or from a single iterable."""
if len(args) == 2:
return tuple.__new__(cls, args)
else:
return tuple.__new__(cls, tuple(*args))
@property
def x(self):
"""Return the first element."""
return self[0]
@property
def y(self):
"""Return the second element."""
return self[1]
def __add__(self, rhs):
return Doublet(self[0] + rhs[0], self[1] + rhs[1])
def __radd__(self, rhs):
return self.__add__(rhs)
def __sub__(self, rhs):
return Doublet(self[0] - rhs[0], self[1] - rhs[1])
def __mul__(self, scalar):
return Doublet(self[0] * scalar, self[1] * scalar)
def __rmul__(self, scalar):
return self.__mul__(scalar)
def __truediv__(self, scalar):
return Doublet(self[0] / scalar, self[1] / scalar)
@property
def length(self):
"""Return the Euclidean length."""
return sqrt(self[0] * self[0] + self[1] * self[1])
[docs]
def dot(self, rhs):
"""Compute the dot product with another two-element sequence."""
return self[0] * rhs[0] + self[1] * rhs[1]
[docs]
def normalized(self):
"""Return a unit-length doublet in the same direction."""
inv_l = 1.0 / self.length()
return Doublet(self[0] * inv_l, self[1] * inv_l)
[docs]
def scaled(self, scale):
"""Return a new doublet with each element multiplied by the corresponding scale factor."""
return Doublet(self[0] * scale[0], self[1] * scale[1])
[docs]
class ReferenceFrame:
"""Coordinate reference frame defined by a basis, scale, and origin point."""
def __init__(self, basis=None, scale=None, reference_point: Point = None):
assert basis is not None or scale is not None
if basis is not None:
self.basis = basis
else:
self.basis = Basis(Vector(scale[0], 0), Vector(0, scale[1]))
assert is_standard_basis(
reference_point.basis
) # We always provide origin in standard coordinates
self.reference_point = reference_point
@property
def scale(self):
"""Return the scale factors as a tuple of basis vector lengths."""
return (self.basis.e0.length, self.basis.e1.length)
[docs]
def convert_to_local(self, point: Point):
"""Convert a point from standard coordinates to local frame coordinates."""
point.change_refence_frame(self.basis, self.origin_position) # noqa: F821
return self.origin + v # noqa: F821
[docs]
def convert_to_canvas(self, local_point):
"""Convert a local-frame point to canvas (standard) coordinates."""
x_c, y_c = (local_point[0] * self.scale[0], local_point[1] * self.scale[1])
return self.origin + self.xHat * x_c + self.yHat * y_c
[docs]
def scale_to_canvas(self, size):
"""Scale a size tuple from local units to canvas units."""
return (size[0] * abs(self.scale[0]), size[1] * abs(self.scale[1]))
[docs]
def scale_to_local(self, size):
"""Scale a size tuple from canvas units to local units."""
return (size[0] / abs(self.scale[0]), size[1] / abs(self.scale[1]))
@property
def unit_vectors_scaled(self):
"""Return the unit vectors multiplied by their respective scale factors."""
return self.xHat * self.scale[0], self.yHat * self.scale[1]
@property
def unit_vectors(self):
"""Return the unit vectors of the frame."""
return self.xHat, self.yHat
[docs]
class TestCase(unittest.TestCase):
"""Unit tests for Vector, Point, and Basis coordinate operations."""
b1 = Basis()
b2 = Basis(e0=Vector(2, 0), e1=Vector(0, 2))
b3 = Basis(e0=Vector(1, -1), e1=Vector(1, 1))
[docs]
def test_point(self):
"""Test basic point creation and standard coordinate conversion."""
p = Point(1, 2)
print(p.standard_coordinates())
p.move_origin(Point(1, 1))
print(p)
p.move_origin(Point(-10, -10))
print(p)
# print(p.standard_coordinates())
[docs]
def test_point_reference_frame_same_origin(self):
"""Test reference frame conversions with the origin at (0, 0)."""
p0 = Point(1, 2)
p1 = Point(1.0, 2.0)
self.assertTrue(p0.is_same_as(p1))
p2 = p0.in_reference_frame(self.b1, Point(0, 0))
self.assertEqual(p2, Point(1, 2))
self.assertEqual(
p0.in_reference_frame(self.b3, Point(0, 0)).standard_coordinates(), p0
)
self.assertEqual(
p0.in_reference_frame(self.b1, Point(0, 0)).standard_coordinates(), p0
)
self.assertEqual(
p0.in_reference_frame(self.b1, Point(0, 0)).standard_coordinates(), p0
)
[docs]
def test_point_reference_frame_new_origin(self):
"""Test reference frame conversions with a shifted origin."""
p0 = Point(1, 2, reference_point=Point(1, 2))
self.assertEqual(p0.standard_coordinates(), Point(2, 4))
p2 = Point(1, 2, basis=self.b2, reference_point=Point(1, 1))
self.assertEqual(p2.standard_coordinates(), Point(3, 5))
p3 = Point(1, 2, basis=self.b3, reference_point=Point(1, 1))
self.assertEqual(p3.standard_coordinates(), Point(4, 2))
[docs]
def test_point_reference_frame_new_origin_not_standard_coordinates(self):
"""Test reference frame with a non-standard-basis origin point."""
p0 = Point(1, 2, reference_point=Point(1, 2, basis=self.b2))
self.assertEqual(p0.standard_coordinates(), Point(3, 6))
p0 = Point(
1,
2,
reference_point=Point(1, 2, basis=self.b2, reference_point=Point(1, 2)),
)
self.assertEqual(p0.standard_coordinates(), Point(4, 8))
[docs]
def test_define_default_basis(self):
"""Test that PointDefault context manager sets a default basis."""
b_local = Basis(e0=Vector(10, 0), e1=Vector(0, 30))
points = []
with PointDefault(points, basis=b_local):
points.append(Point(0, 0))
points.append(Point(10, 3))
points.append(Point(10, 3))
points.append(Point(10, 3))
for point in points:
self.assertTrue(point.basis == b_local)
[docs]
def test_define_default_reference(self):
"""Test that PointDefault context manager sets a default reference point."""
ref = Point(1, 2)
points = []
with PointDefault(points, reference_point=ref):
points.append(Point(0, 0))
points.append(Point(10, 3))
points.append(Point(10, 3))
points.append(Point(10, 3))
for point in points:
self.assertTrue(point.reference_point == ref)
[docs]
def test_define_default(self):
"""Test PointDefault with both basis and reference point."""
ref = Point(1, 2)
b_local = Basis(e0=Vector(10, 0), e1=Vector(0, 30))
points = []
with PointDefault(points, basis=b_local, reference_point=ref):
points.append(Point(0, 0))
points.append(Point(10, 3))
points.append(Point(10, 3))
points.append(Point(10, 3))
for point in points:
self.assertTrue(point.reference_point == ref)
[docs]
def test_get_line_in_canvas_coordinates(self):
"""Test converting a sequence of local-basis points to standard coordinates."""
b_local = Basis(e0=Vector(10, 0), e1=Vector(0, -30))
points = []
with PointDefault(points, basis=b_local, reference_point=Point(100, 300)):
points.append(Point(0, 0))
points.append(Point(10, 3))
points.append(Point(20, -3))
points.append(Point(60, 20))
for point in [point.standard_coordinates() for point in points]:
print(point)
# def test_refrence_frame_origin(self):
# b1 = Basis(e0=Vector(1,0), e1=Vector(0,1))
# ref = ReferenceFrame(basis=b1, origin_position=Point(0,0))
# self.assertEqual(ref.convert_to_local(Point(0, 0)), (0, 0))
# self.assertEqual(ref.convert_to_local(Point(1, 1)), (1, -1))
# ref = ReferenceFrame(scale=(2, 2))
# self.assertEqual(ref.convert_to_local(Vector(0, 0)), (0, 0))
# self.assertEqual(ref.convert_to_local(Vector(1, 1)), (0.5, -0.5))
# ref = ReferenceFrame(scale=(10, 100))
# self.assertEqual(ref.convert_to_local(Vector(0, 0)), (0, 0))
# self.assertEqual(ref.convert_to_local(Vector(1, 1)), (0.1, -0.01))
# def test_refrence_frame_new_origin(self):
# ref = ReferenceFrame(basis=Basis(), origin_position=Vector(1, 1))
# self.assertEqual(ref.convert_to_local(Vector(0, 0)), (-1, 1))
# self.assertEqual(ref.convert_to_local(Vector(1, 1)), (0, 0))
# ref = ReferenceFrame(scale=(2, 2), origin_position=Vector(1, 1))
# self.assertEqual(ref.convert_to_local(Vector(0, 0)), (-0.5, 0.5))
# self.assertEqual(ref.convert_to_local(Vector(1, 1)), (0, 0))
# ref = ReferenceFrame(scale=(10, 100), origin_position=Vector(1, 1))
# self.assertEqual(ref.convert_to_local(Vector(1, 1)), (0, 0))
# self.assertEqual(ref.convert_to_local(Vector(2, 2)), (0.1, -0.01))
[docs]
def test_point_subtraction(self):
"""Test that subtracting two points produces a Vector."""
p1 = Point(1, 2)
p2 = Point(3, 4)
v = p2 - p1
self.assertTrue(isinstance(v, Vector))
[docs]
def test_vector(self):
"""Test basic vector dot product computation."""
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v2.dot(v1))
[docs]
def test_basis(self):
"""Test point subtraction within a non-standard basis."""
b = Basis(Vector(1, -1), Vector(1, 1))
p1 = Point(1, 2, basis=b)
p2 = Point(3, 4, basis=b)
print(p2 - p1)
[docs]
def test_basis_change(self):
"""Test changing a vector from one basis to another."""
b1 = Basis(e0=Vector(1, -1), e1=Vector(1, 1))
_b2 = Basis()
v1 = Vector(1, 2, basis=b1)
print(v1)
print(v1.standard_coordinates())
print(v1.length)
print(v1.standard_coordinates().length)
print(v1.change_basis(b1).length)
# print(v1.change_basis(b1))
# print(v1.change_basis(b2))
[docs]
def test_basis_change2(self):
"""Test changing basis with a scaled (non-unitary) basis."""
_b3 = Basis(e0=Vector(1, -1), e1=Vector(1, 1))
b2 = Basis(e0=Vector(2, 0), e1=Vector(0, 2))
b1 = Basis()
v1 = Vector(1, 3, basis=b1)
print(v1)
print(v1.change_basis(b2))
# print(v1.standard_coordinates())
# v2 = Vector(0.5, 1, basis=b2)
# print(v2)
# print(v2.standard_coordinates())
# v2.change_basis(b1)
# print(v2)
# print(v2.standard_coordinates())
# v2.change_basis(b3)
# print(v2)
# print(v2.standard_coordinates())
# # print(v1.change_basis(b1))
# # print(v1.change_basis(b2))
if __name__ == "__main__":
# unittest.main()
unittest.main(defaultTest=["TestCase.test_get_line_in_canvas_coordinates"])