As part of a game engine I am creating, I am in the process of writing a bunch of math classes (vector2/3/4, matrix2/3/4, and quaternions to start with).
Vec2
class:
import math
import random
class Vec2(object):
def __init__(self, x, y):
self._x = float(x)
self._y = float(y)
@property
def x(self):
return self._x
@x.setter
def x(self, new_x):
self._x = float(new_x)
@property
def y(self):
return self._y
@y.setter
def y(self, new_y):
self._y = float(new_y)
def __add__(self, other):
types = (int, float)
if isinstance(self, types):
return Vec2(self + other.x, self + other.y)
elif isinstance(other, types):
return Vec2(self.x + other, self.y + other)
else:
return Vec2(self.x + other.x, self.y + other.y)
def __div__(self, other):
types = (int, float)
if isinstance(self, types):
self = Vec2(self, self)
elif isinstance(other, types):
other = Vec2(other, other)
x = self.x / other.x
y = self.y / other.y
return Vec2(x, y)
def __mul__(self, other):
types = (int, float)
if isinstance(self, types):
return Vec2(self * other.x, self * other.y)
elif isinstance(other, types):
return Vec2(self.x * other, self.y * other)
else:
return Vec2(self.x * other.x, self.y * other.y)
def __neg__(self):
return Vec2(-self.x, -self.y)
def __radd__(self, other):
return Vec2(self.x + other, self.y + other)
def __rdiv__(self, other):
return Vec2(other/self.x, other/self.y)
def __rmul__(self, other):
return Vec2(other * self.x, other * self.y)
def __rsub__(self, other):
return Vec2(other - self.x, other - self.y)
def __repr__(self):
return self.__str__()
def __str__(self):
return "Vec2: ({0}, {1})".format(self.x, self.y)
def __sub__(self, other):
types = (int, float)
if isinstance(self, types):
return Vec2(self - other.x, self - other.y)
elif isinstance(other, types):
return Vec2(self.x - other, self.y - other)
else:
return Vec2(self.x - other.x, self.y - other.y)
def ceil(self):
return Vec2(math.ceil(self.x), math.ceil(self.y))
def floor(self):
return Vec2(math.floor(self.x), math.floor(self.y))
def get_data(self):
return (self.x, self.y)
def inverse(self):
return Vec2(1.0/self.x, 1.0/self.y)
def length(self):
return math.sqrt(self.square_length())
def normalize(self):
length = self.length()
if length == 0.0:
return Vec2(0, 0)
return Vec2(self.x/length, self.y/length)
def round(self):
return Vec2(round(self.x), round(self.y))
def square_length(self):
return (self.x * self.x) + (self.y * self.y)
"""
def transform(self, matrix):#mat2, mat2d, mat3, mat4
pass
@classmethod
def cross(cls, a, b):
z = (a.x * b.y) - (a.y * b.x)
return Vec3(0, 0, z)
"""
@classmethod
def distance(cls, a, b):
c = b - a
return c.length()
@classmethod
def dot(self, a, b):
return (a.x * b.x) + (a.y * b.y)
@classmethod
def equals(cls, a, b, tolerance=0.0):
diff = a - b
dx = math.fabs(diff.x)
dy = math.fabs(diff.y)
if dx <= tolerance * max(1, math.fabs(a.x), math.fabs(b.x)) and \
dy <= tolerance * max(1, math.fabs(a.y), math.fabs(b.y)):
return True
return False
@classmethod
def max(cls, a, b):
x = max(a.x, b.x)
y = max(a.y, b.y)
return Vec2(x, y)
@classmethod
def min(cls, a, b):
x = min(a.x, b.x)
y = min(a.y, b.y)
return Vec2(x, y)
@classmethod
def mix(cls, a, b, t):
return a * t + b * (1-t)
@classmethod
def random(cls):
x = random.random()
y = random.random()
return Vec2(x, y)
@classmethod
def square_distance(cls, a, b):
c = b - a
return c.square_length()
And here is how it can currently be used in my engine:
from pyorama.math3d.vec2 import Vec2
a = Vec2(1, 2.5)
b = Vec2(5, -1)
print a+1, 1+a, a+b, b+a
print a-1, 1-a, a-b, b-a
print a*2, 2*a, a*b, b*a
print a/3, 3/a, a/b, b/a
print -a, -b
a.x = 3.0
a.y = 4
print type(a.x), type(a.y), a
print a.ceil()
print a.floor()
print a.get_data()
print a.inverse()
print a.length()
print a.normalize()
print a.round()
print a.square_length()
#print a.transform(None)#not implemented
#print Vec2.cross(a, b)#not implemented
print Vec2.distance(a, b)
print Vec2.dot(a, b)
print Vec2.equals(a, b, tolerance=200)
print Vec2.max(a, b)
print Vec2.min(a, b)
print Vec2.mix(a, b, 0.5)
print Vec2.random()
print Vec2.square_distance(a, b)
My major concern is whether or not it would be better to organize the code as it is currently, in a module, in a purely OOP manner (without @classmethod
) or in a @classmethod
only manner. Here are the advantages as I see them:
- My current organization is inconsistent as it mixes instance (when there is one
Vec2
argument) and class methods (when there are twoVec2
arguments) together. This might be confusing for new users, but it could make writing math out in code much easier. - A
vec2
module instead could lead to better performance (since I am not creating a bunch ofVec2
objects and would be returning tuples/lists instead). However, I would not have any niceties such as operator overloading. - Avoiding
@classmethod
would make the code more consistent and could allow chaining, but it may be difficult to reada.dot(b)
versusVec2.dot(a, b)
. - Using only
@classmethod
would make the code more consistent in usage (everything isVec2.something()
but chaining operations would be impossible. Operator overloading could also be annoying to deal with.
Another minor issue I have is with the names of my functions. Is it better to abbreviate the names and replace Vec2.distance(a, b)
with Vec2.dist(a, b)
in order to be consistent with the abbreviated name of the class or should I leave them spelled out instead (and perhaps rename Vec2
to Vector2
)? I was also not sure if is it acceptable to use built-in function names in my custom class (such as len
or round
) as while I am not polluting the namespace and I cannot think of adequate alternative names, it can lead to improper syntax highlighting.
I know I am asking a lot of probably trivial stylistic questions, but I would greatly appreciate any advice!
Following @ChrisR's helpful idea of creating base vector and matrix classes using numpy that can be extended, I have created a Vec
class and a Vec2
class. Here are both of those classes as they currently stand:
vec.py:
import math
import numpy as np
import random
class Vec(object):
def __init__(self, data):
self.data = data
def __add__(self, other):
if isinstance(other, Vec):
return Vec(self.data + other.data)
return Vec(self.data + other)
def __radd__(self, other):
return Vec(other + self.data)
def __sub__(self, other):
if isinstance(other, Vec):
return Vec(self.data - other.data)
return Vec(self.data - other)
def __rsub__(self, other):
return Vec(other - self.data)
def __mul__(self, other):
if isinstance(other, Vec):
return Vec(self.data * other.data)
return Vec(self.data * other)
def __rmul__(self, other):
return Vec(other * self.data)
def __div__(self, other):
if isinstance(other, Vec):
return Vec(self.data / other.data)
return Vec(self.data / other)
def __rdiv__(self, other):
return Vec(other / self.data)
def __neg__(self):
return Vec(-self.data)
def __pos__(self):
return Vec(+self.data)
def __eq__(self, other):
return np.array_equal(self.data, other.data)
def __ne__(self, other):
return not self.__eq__(other)
def __lt__(self, other):
return self.square_length() < other.square_length()
def __le__(self, other):
return self.square_length() <= other.square_length()
def __gt__(self, other):
return self.square_length() > other.square_length()
def __ge__(self, other):
return self.square_length() >= other.square_length()
def __repr__(self):
return self.__str__()
def __str__(self):
return np.array_str(self.data)
def ceil(self):
return Vec(np.ceil(self.data))
def floor(self):
return Vec(np.floor(self.data))
def get_data(self):
return self.data
def inverse(self):
return Vec(1.0/self.data)
def length(self):
return float(np.linalg.norm(self.data))
def normalize(self):
length = self.length()
if length == 0.0:
return Vec(np.zeros(self.data.shape()))
return Vec(self.data/length)
def round(self, decimal=0):
return Vec(np.round(self.data, decimal))
def square_length(self):
return float(np.sum(np.square(self.data)))
@classmethod
def distance(cls, a, b):
c = b - a
return c.length()
@classmethod
def dot(self, a, b):
return Vec(np.dot(a.data, b.data))
@classmethod
def equals(cls, a, b, tolerance=0.0):
diffs = np.fabs((a - b).data)
pairs = zip(list(np.fabs(a.data)), list(np.fabs(b.data)))
tolerance_calcs = [tolerance * max(1, a_val, b_val) for (a_val, b_val) in pairs]
tests = [d <= t for (d, t) in zip(diffs, tolerance_calcs)]
return all(tests)
@classmethod
def max_components(cls, a, b):
return Vec(np.maximum(a.data, b.data))
@classmethod
def min_components(cls, a, b):
return Vec(np.minimum(a.data, b.data))
@classmethod
def mix(cls, a, b, t):
return a*(1-t) + b*t
@classmethod
def random(cls, n):
x = random.random()
y = random.random()
return Vec(np.random.rand((n)))
@classmethod
def square_distance(cls, a, b):
c = b - a
return c.square_length()
vec2.py:
from vec import Vec
import math
import numpy as np
import random
class Vec2(Vec):
def __init__(self, x, y):
self._x = float(x)
self._y = float(y)
super(Vec2, self).__init__(np.array([x, y], dtype=np.float32))
@property
def x(self):
return self._x
@x.setter
def x(self, new_x):
self._x = float(new_x)
self.data[0] = self._x
@property
def y(self):
return self._y
@y.setter
def y(self, new_y):
self._y = float(new_y)
self.data[1] = self._y
This is definitely a lot more convenient, although the numpy arithmetic leads to some interesting floating point rounding errors. I renamed min/max
to min_components/max_components
to clear up any confusion and added > and < comparisons that compare based on vector magnitude.
However, this does introduce a new issue. One is the random function in Vec2
needs to be called like Vec2.random(2)
which is ugly. In that case, should I even implement a random function in the base Vec
class, or is there a way to keep both Vec.random(n)
as well as Vec2.random()
?
Additionally, I was trying to move functions like dot
to the module level, but then wouldn't users have to call both of these imports if they were dotting two Vec2
vectors:
from pyorama.math3d.vec import dot
from pyorama.math3d.vec2 import Vec2
I would rather that users not even touch the base Vec
class at all! Is there a cleaner way to do imports if I switch the dot
function code to a module level, either by doing some import trickery in the __init__.py
file in the math3d
folder where all this code is or in vec.py
or vec2.py
?
For anyone interested in what the "final" code looks like, I now have completed writing code for the Vec2/3/4
, Mat2/3/4
, and Quat
classes. This can all be found in this github repository under the math3d
folder. Thanks for all of your feedback and help!
vec
class tonight along with an extendedvec2
class and update the question. I will also have to figure out how to do x, y, z, and w properties for each of the special cases of thevec
class. Do you have any advice about the function names and the use/abuse of@classmethod
? \$\endgroup\$ – CodeSurgeon Dec 30 '16 at 4:57