Skip to main content

A 2-dimensional vector class designed to be convenient

Project description

vec

A reasonable, performant 2D vector object for games

Copyright 2019-2024 by Larry Hastings

Overview

vec is a module currently publishing one class: Vector2, a 2D vector object designed for game development.

Features:

  • Vector2 objects are immutable.
  • Vector2 support all the usual vector object features, including operator overloading.
  • Attributes of Vector2 objects are lazily-computed where possible.
  • Vector2 objects effortlessly support both cartesian and polar coordinates.

vec supports Python 3.6+, and passes its unit test suite with 100% coverage.

Why Another Vector Class?

I've participated in four PyWeek gaming challenges. And twice, mid-week, I wrote my own vector class out of sheer frustration.

The biggest problem with most Python vector objects in is that they're mutable. Frankly this way lies madness. Vector objects should be immutable--it just makes sense from an API perspective. What if you set the position of some game-engine pawn to be a particular vector object, then modify that vector object? Should the pawn update its position automatically--and if so, how would it know the value changed?

Similarly, some vector classes use degrees for polar coordinates instead of radians. Again this way lies madness. The trigonometric functions in Python's math module operate in the radians domain, and having to keep track of which domain something is in--and translate back and forth--is a needless conceptual complication. You've got a game to write!

(Some vector classes support both radians and degrees for polar coordinates. This is bad API design--it doubles the surface area of your API, adding needless complexity and increasing maintenance and testing overhead. Embrace the radian, folks.)

On a related note, many vector classes make polar coordinates second-class citizens. Most vector classes only store vectors in cartesian coordinates, so either the programmer must perform all polar operations externally to the vector objects, or they incur the overhead and cumulative error of translating to polar and back again with every operation.

vec's Vector2 avoids all these problems:

  • Vector2 objects are immutable,
  • they make polar and cartesian coordinates both first-class citizens, and
  • they strictly use radians for polar coordinates.

The Conceptual Model

Vector2 objects conceptually represent a vector. They can be defined using either cartesian or polar coordinates, and any Vector2 can be queried for both its cartesian and polar coordinates.

Most vector objects in games are defined using cartesian coordinates. Vector2 makes that easy, supporting any number of invocations to create one. Discrete parameters, iterables, and objects that support x and y attributes all work fine:

Vector2(0, 1)
Vector2(x=0, y=1)
Vector2((0, 1))
Vector2([0, 1])
Vector2(iter([0, 1]))
Vector2({'x':0, 'y':1})
Vector2(types.SimpleNamespace(x=0, y=1))

All these define the same vector. That last example is there to demonstrate that Vector2 can create a vector based on any object with x and y attributes.

Every Vector2 object supports both cartesian and polar coordinates. You can define a Vector2 using cartesian coordinates, then examine its polar coordinates. This:

v = vec.Vector2(0, 1)
print(v.theta, v.r)

prints 1.5707963267948966 1.0. That first number is π/2 (approximately).

Conversely, you can define a Vector2 object using polar coordinates, then examine its cartesian coordinates:

v2 = vec.Vector2(r=1, theta=math.pi/2)
print(v2.x, v2.y)

This prints 6.123233995736766e-17 1.0. Conceptually this should print 0.0, 1.0--but math.pi is only an approximation, which means our result has an infinitesimal error.

Implementation Details

To define a valid Vector2 object, you must have a complete set of either cartesian or polar coordinates--either is sufficient. All other attributes will be lazily computed on demand.

Vector2 objects use slots, and rely on __getattr__ to implement this lazy computation. Only the known values of the vector are set when it's created. If the user refers to an attribute that hasn't been computed yet, Python will call Vector2.__getattr__(), which computes, caches, and returns that value. Future references to that attribute skip this mechanism and simply return the cached value, which is only as expensive as an attribute lookup on a conventional object.

Operations on Vector2 objects compute their result using the cheapest approach. If you have a Vector2 object defined using polar coordinates, and you call .rotate() or .scale() on it, all the math is done in the polar domain. On the other hand, adding vectors is almost always done in the cartesian domain, so if you add a polar vector to any other vector, its cartesian coordinates will likely be computed--and the resulting vector will always be defined using cartesian coordinates.

What's the exception? There's a special case for adding two polar vectors which have the exact same theta: just add their r values. That approach is much cheaper than converting to cartesian, and more precise as well, returning a vector defined using polar coordinates! Vector2 takes advantage of many such serendipities, performing your vector math as cheaply and accurately as possible.

The API

Vector2(x=None, y=None, *, r=None, theta=None, r_squared=None)

Constructs a Vector2 object. You may pass in as many or as few of these arguments as you like; however, you must pass in either both x and y or both r and theta. Any attributes not passed in at construction time will be lazily computed at the time they are evaluated.

You can also pass in a single object which will initialize the vector. Supported objects include:

  • an existing Vector2 object (just returns that object),
  • an object which has .x and .y attributes,
  • a mapping object with exactly two keys, 'x' and 'y', and
  • an ordered iterable object with exactly two elements, which will be used as 'x' and 'y' respectively.

Vector2 only does some validation of its arguments. It ensures that r and theta are normalized. However, it doesn't check that (x, y) and (r, theta) describe the same vector. If you pass in x and y, and also pass in a theta and r that don't match, you'll get back the Vector2 that you asked for. Good luck!

Attributes

Vector2 objects support five attributes: x, y, r, theta, and r_squared. It doesn't matter whether the object was defined with cartesian or polar coordinates, they'll all work.

r_squared is equivalent to r*r. But if you have a Vector2 object defined with cartesian coordinates, it's much cheaper to compute r_squared than r. And there are many use cases where r_squared works just as well as r.

For example, consider collision detection in a game. One way to decide whether two objects are colliding is to measure the distance between them--if it's less than a certain distance R, the two objects are colliding. But computing the actual distance is expensive--it requires a time-consuming square root. It's much cheaper to compute the distance-squared between the two points. If that's less than R2, the two objects are colliding.

Operators and protocols

Vector2 objects support the iterator protocol. len() on a Vector2 object will always return 2. You can also iterate over a Vector2 object, which will yield the x and y attributes in that order.

Vector2 objects support the sequence protocol. You can subscript them, which behaves as if the Vector2 object is a tuple of length 2 containing the x and y attributes.

Vector2 objects support the boolean protocol; you may use them with boolean operators, and you may call bool() on them. When used in a boolean context, the zero vector evaluates to False, and all other vectors evaluate to True.

Vector2 objects are hashable, but they're not ordered. (You can't ask if one vector is less than another.)

Vector2 objects support the following operators:

  • v1 + v2 adds the two vectors together.
  • v1 - v2 subtracts the right vector from the left vector.
  • v1 * scalar mulitplies the vector by a scalar amount, equivalent to v1.scale(scalar).
  • v1 / scalar divides the vector by a scalar amount.
  • +v1 is exactly the same as v1.
  • -v1 returns the opposite of v1, such that v1 + (-v1) should be the zero vector. (This may not always be the case due to floating-point imprecision.)
  • v1 == v2 is True if the two vectors are exactly the same, and False otherwise. For consistency, this only compares cartesian coordinates. Note that floating-point imprecision may result in two vectors that should be the same failing an == check. Consider using the almost_equal method, which allows for some imprecision in its comparison.
  • v1 != v2 is False if the two vectors are exactly the same, and True otherwise. For consistency, this only compares cartesian coordinates. Note that floating-point imprecision may result in two vectors that should be the same passing an != check. Again, consider using the almost_equal method and negating the results.
  • v[0] and v.x evaluate to the same number.
  • v[1] and v.y evaluate to the same number.
  • list(v) is the same as [v.x, v.y].

Class methods

vec.from_polar(r, theta)

Constructs a Vector2 object from the two polar coordinates r and theta.

You can also pass in a single object which will be used to initialize the vector. Supported objects include:

  • an existing Vector2 object (just returns that object),
  • an object which has .r and .theta attributes,
  • a mapping object with exactly two keys, 'r' and 'theta', and
  • an ordered iterable object with exactly two elements, which will be interpreted as 'r' and 'theta' in that order.

If r is 0, theta must be None, and from_polar will return the zero vector. If r is not 0, theta must not be None.

Methods

Vector2 objects support the following methods:

Vector2.almost_equal(other, places)

Returns True if the vector and other are the same vector, down to places decimal places. Like the Vector2 class's support for the == operator, the comparison is only done using cartesian coordinates, for consistency.

Vector2.scaled(scalar)

Returns a new Vector2 object, equivalent to the original vector multiplied by that scalar.

Vector2.scaled_to_length(r)

Returns a new Vector2 object, equivalent to the original vector with its length set to r.

Vector2.normalized()

Returns a new Vector2 object, equivalent to the original vector scaled to length 1.

Vector2.rotated(theta)

Returns a new Vector2 object, equal to the original vector rotated by theta radians.

Vector2.dot(other)

Returns the "dot product" selfother. This result is a scalar value, not a vector.

Vector2.cross(other)

Returns the "cross product" selfother. This result is a scalar value, not a vector.

Note: technically, there is no "cross product" defined for 2-dimensional vectors. This actually returns the "perpendicular dot product" of the two vectors, because that's what people generally mean when they ask for the "cross product" of two 2D vectors.

Vector2.polar()

Returns a 2-tuple of (self.r, self.theta).

Vector2.lerp(other, ratio)

Returns a vector representing a linear interpolation between self and other, according to the scalar ratio ratio. ratio should be a value between (and including) 0 and 1. If ratio is 0, this returns self. If ratio is 1, this returns other. If ratio is between 0 and 1 non-inclusive, this returns a point on the line segment defined by the two endpoints self and other, with the point being ratio between self and other. For example, if ratio is 0.4, this returns (self * 0.6) + (other * 0.4).

Note that it's not an error to specify a ratio less than 0 or greater than 1, and ratio isn't clamped to this range.

Vector2.slerp(other, ratio, *, reflected=None, epsilon=1e-6)

Returns a vector representing a spherical interpolation between self and other, according to the scalar ratio ratio. ratio should be a value between (and including) 0 and 1. If ratio is 0, this returns self. If ratio is 1, this returns other.

Note that it's not an error to specify a ratio less than 0 or greater than 1, and ratio isn't clamped to this range.

To facilitate compatibility with pygame, by default slerp produces identical results to pygame.math.Vector2.slerp. This includes the following behavior: when ratio is < 0, slerp negates ratio and traces an alternate path between self and other. This makes it impossible to use actual negative values for ratio.

If you want to use actual negative ratios, specify the reflected parameter. This both specifies which path to trace and disables reflecting ratio around 0. reflected=False selects the path normally traced by positive ratios, and reflected=True selects the path normally traced by negative ratios.

(A previous implementation of slerp produced different results; it's still available, with the new name Vector2.vec_slerp. It doesn't accept the epsilon argument.)

Vector2.nlerp(other, ratio)

Returns a vector representing a normalized linear interpolation between self and other, according to the scalar ratio ratio. ratio should be a value between (and including) 0 and 1. If ratio is 0, this returns self. If ratio is 1, this returns other.

Note that it's not an error to specify a ratio less than 0 or greater than 1, and ratio is not clamped to this range.

Constants

vector2_zero

The "zero" Vector2 vector object. vec guarantees that every zero vector is a reference to this object:

>>> v = vec.Vector2(0, 0)
>>> v is vec.vector2_zero
True

Mathematically-speaking, the zero vector when expressed in polar coordinates doesn't have a defined angle. Therefore vec defines its zero vector as having an angle of None. The zero vector must have r set to zero and theta set to None, and any other vector must have a non-zero r and theta set to a numeric value.

vector2_1_0

A predefined Vector2 vector object, equivalent to Vector2(1, 0). When constructing a Vector2 object that is exactly equivalent to this vector, the Vector2 constructor will always return a reference to this vector:

>>> v = vec.Vector2(1, 0)
>>> v is vec.vector2_1_0
True
>>> v2 = vec.Vector2(r=1, theta=0)
>>> v2 is vec.vector2_1_0
True

vector2_0_1

A predefined Vector2 vector object, equivalent to Vector2(0, 1). When constructing a Vector2 object that is exactly equivalent to this vector, the Vector2 constructor will always return a reference to this vector:

>>> v = vec.Vector2(0, 1)
>>> v is vec.vector2_0_1
True
>>> v2 = vec.Vector2(r=1, theta=pi/2)
>>> v2 is vec.vector2_0_1
True

vector2_1_1

A predefined Vector2 vector object, equivalent to Vector2(1, 1). When constructing a Vector2 object that is exactly equivalent to this vector, the Vector2 constructor will always return a reference to this vector:

>>> v = vec.Vector2(1, 1)
>>> v is vec.vector2_1_1
True
>>> v2 = vec.Vector2(r=2 ** 0.5, theta=pi/4)
>>> v2 is vec.vector2_1_1
True

Extending vec to handle other types

vec does some input verification on its inputs. Coordinates--x, y, r, theta--are required to be either int or float. (Technically theta can also be None.) This best serves the intended use case of vec as a 2D vector library for game programming in Python.

If you want to experiment with vec for other use cases, you may want vec to permit other types to be valid coordinates. vec provides a simple mechanism to allow this. Simply call:

    vec.permit_coordinate_type(T)

before creating your vector, passing in the type you want to use as a coordinate as T, and vec will now accept objects of that type as coordinates.

Note that the types you extend vec with in this manner should behave like numeric types, like int and float.

Changelog

0.7.1 2024/09/15

  • Added the reflected parameter to Vector2.slerp, allowing you to render actual negative ratios. For compatibility with pygame.math.Vector2.slerp, negative ratios normally trace an alternate path using a symmetric unit circle. Specifying a boolean value for reflected disables this behavior, letting you explicitly select which path you want.

0.7 2024/09/15

  • Breaking change: Retooled support for slerp (spherical linear interpolation) to increase cross-library compatibility:
    • vec's old implementation has been renamed vec_slerp.
    • There's a new method, pygame_slerp, which produces essentially identical results to pygame.math.Vector2.slerp.
    • The Vector2.slerp method defaults to pygame_slerp.
    • vec_slerp now raises TypeError (instead of ValueError) if ratio is the wrong type. (Makes sense, right?)
  • Change the repr for Vector2 objects. If both x and y are set, and they're both integers, it's more pleasant to see only those values, even if some/all of the other values are cached. This makes repr(vector2_zero) a lot easier to read.
  • Updated copyright dates to 2024.

0.6.3 2023/10/26

  • Added three new predefined vectors:

    • vector2_0_1 is Vector2(0, 1)
    • vector2_1_0 is Vector2(1, 0)
    • vector2_1_1 is Vector2(1, 1)

    Any expression that results in a vector that would be exactly equal to one of these vectors is guaranteed to return the predefined vector. Vector2(1, 0) is vector2_1_0 evaluates to True.

0.6.2 2023/06/14

  • Added Vector2.almost_equal, which supports testing for slightly-inexact equality.

0.6.1 2023/06/14

  • Enhanced the Vector2 constructor: now it also accepts mappings. The mapping must have exactly two elements, x and y.
  • Enhanced Vector2.from_polar. It now accepts all the same stuff as the Vector2 constructor: Vector2 objects, namespaces, mappings, and iterables. Where it examines names (attributes, keys) it naturally uses r and theta instead of x and y.

0.6 2023/06/14

A major improvement!

  • vec now has a proper test suite.

  • vec now passes its test suite with 100% coverage.

  • vec explicitly supports Python 3.6+.

  • Added more shortcut optimizations, e.g. rotating a cartesian vector by a multiple of pi/2.

  • Tightened up the metaclass __call__ logic a great deal.

  • Implemented Vector2.slerp, and added Vector2.nlerp.

  • Allowed vec.permit_coordinate_type, to allow extending the set of permissible types for coordinates.

  • Internal details:

    • Now cache _cartesian and _hash internally, as well as _polar. (A vector can have a complete set of both cartesian and polar coordinates, so it's nice to know everything that's available--that can make some operations faster.)
  • Bugfix: Vector2.dot() was simply wrong, it was adding where it should have been multiplying. Fixes #3.

0.5 2021/03/21

Initial version.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

vec-0.7.1.tar.gz (33.0 kB view details)

Uploaded Source

Built Distribution

vec-0.7.1-py3-none-any.whl (17.7 kB view details)

Uploaded Python 3

File details

Details for the file vec-0.7.1.tar.gz.

File metadata

  • Download URL: vec-0.7.1.tar.gz
  • Upload date:
  • Size: 33.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: python-requests/2.31.0

File hashes

Hashes for vec-0.7.1.tar.gz
Algorithm Hash digest
SHA256 afec651b457dba6afae810f409315f587305310e5297af5d7326f8191df24d17
MD5 a5e5bff5db180b817dbe7acd58dd0b22
BLAKE2b-256 1b4b0390380ed6f0ee3ad20b89cd0c45c34d6b92202903a3c57ec15513883f30

See more details on using hashes here.

File details

Details for the file vec-0.7.1-py3-none-any.whl.

File metadata

  • Download URL: vec-0.7.1-py3-none-any.whl
  • Upload date:
  • Size: 17.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: python-requests/2.31.0

File hashes

Hashes for vec-0.7.1-py3-none-any.whl
Algorithm Hash digest
SHA256 3e77bffee2884b7151a4a200e38c60d8a80cc1c4dd0e1f44d3249bf3a2eea33b
MD5 49e6c9234835d1936be6455b92521b0e
BLAKE2b-256 5174cd1ff87cd5703acdcab829f6a419b01d74ef0097da70cefd7f722e2c36da

See more details on using hashes here.

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page